├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ ├── checks.yaml │ └── publish.yml ├── .gitignore ├── .vscode └── launch.template.json ├── LICENSE.md ├── Makefile ├── README.md ├── cmd └── cli │ └── cli.go ├── cs-demos └── .gitignore ├── debug └── .gitignore ├── download-demos.sh ├── go.mod ├── go.sum ├── internal ├── bitread │ └── bitread.go ├── converters │ └── converters.go ├── csv │ └── csv.go ├── demo │ ├── demo.go │ ├── sharecode.go │ └── sharecode_test.go ├── filepath │ └── filepath.go ├── math │ └── math.go ├── slice │ └── slice.go └── strings │ └── strings.go ├── js ├── package-lock.json ├── package.json ├── src │ ├── cli.ts │ ├── constants.ts │ ├── index.ts │ └── platform.ts └── tsconfig.json ├── pkg ├── api │ ├── 5eplay.go │ ├── analyzer.go │ ├── bomb_defuse_start.go │ ├── bomb_defused.go │ ├── bomb_exploded.go │ ├── bomb_plant_start.go │ ├── bomb_planted.go │ ├── challengermode.go │ ├── chat_message.go │ ├── chicken_death.go │ ├── chicken_position.go │ ├── clutch.go │ ├── constants │ │ ├── demo_source.go │ │ ├── demo_type.go │ │ ├── economy.go │ │ ├── export_format.go │ │ ├── game.go │ │ ├── game_type.go │ │ ├── round_win_status.go │ │ ├── team_letter.go │ │ └── weapon.go │ ├── damage.go │ ├── decoy_start.go │ ├── demo_source.go │ ├── ebot.go │ ├── economy.go │ ├── esea.go │ ├── esplay.go │ ├── esportal.go │ ├── export_csdm.go │ ├── export_csv.go │ ├── export_format.go │ ├── export_json.go │ ├── faceit.go │ ├── fastcup.go │ ├── flashbang_explode.go │ ├── grenade_bounce.go │ ├── grenade_position.go │ ├── grenade_projectile_destroy.go │ ├── he_grenade_explode.go │ ├── hostage_pick_up_start.go │ ├── hostage_picked_up.go │ ├── hostage_position.go │ ├── hostage_rescued.go │ ├── inferno_position.go │ ├── kill.go │ ├── match.go │ ├── matchzy.go │ ├── player.go │ ├── player_buy.go │ ├── player_economy.go │ ├── player_flashed.go │ ├── player_position.go │ ├── renown.go │ ├── round.go │ ├── shot.go │ ├── smoke_start.go │ ├── team.go │ ├── valve.go │ └── weapon.go └── cli │ └── cli.go ├── prettier.config.js └── tests ├── assertion ├── assert_clutches.go ├── assert_player_economies.go ├── assert_players.go └── assert_rounds.go ├── cs2_5eplay_6c306e56_8170_4092_b402_08dbf813e452_2023_mirage_test.go ├── cs2_5eplay_6c306e56_8170_4092_b402_08dbf813e452_2023_nuke_test.go ├── cs2_cm_6c306e56_8170_4092_b402_08dbf813e452_2023_anubis_test.go ├── cs2_ebot_monte_vs_og_2023_anubis_test.go ├── cs2_esplay_nnQccdWWJtkc_2025_ancient_test.go ├── cs2_esplay_ntfNCNcmKCQc_2025_mirage_test.go ├── cs2_esplay_nvBBvqNCfFHV_2025_train_test.go ├── cs2_esportal_6008132_2023_mirage_test.go ├── cs2_esportal_6045888_2024_mirage_test.go ├── cs2_fastcup_11851975_11876310_202312171749_competitive_2023_mirage_test.go ├── cs2_matchzy_aurora_vs_3dmax_m3_2024_anubis_test.go ├── cs2_matchzy_bleed_vs_parivision_2024_mirage_test.go ├── cs2_matchzy_iskandear_vs_kirill_2024_train_test.go ├── cs2_matchzy_pressure_vs_cyphin_2024_nuke_test.go ├── cs2_renown_match_1363_2025_ancient_test.go ├── cs2_renown_match_8_2025_mirage_test.go ├── csgo_cm_saw_vs_astralis_paris_2023_cq_inferno_test.go ├── csgo_cm_saw_vs_astralis_paris_2023_cq_vertigo_test.go ├── csgo_ebot_astralis_vs_envyus_game_show_global_esports_cup_2016_cache_test.go ├── csgo_ebot_cloud9_vs_nip_iem_oakland_2016_train_test.go ├── csgo_ebot_efrag_net_vs_faze_iem_oakland_2016_cache_test.go ├── csgo_ebot_galatics_vs_nerdrage_alientech_csgo_league_season1_2016_cache_test.go ├── csgo_ebot_immortals_vs_north_iem_katowice_2017_overpass_test.go ├── csgo_ebot_optic_vs_faze_iem_oakland_2017_overpass_test.go ├── csgo_esea_clg_vs_liquid_iem_cologne_2017_cbble_test.go ├── csgo_esea_match_12283595_cache.go ├── csgo_faceit_2d199377-f179-4b48-8ac7-e5714c754639_mirage_test.go ├── csgo_fastcup_11851975_11876310_202312171749_competitive_2023_dust2_test.go ├── csgo_valve_match730_003402256765125919145_0103110035_190_nuke_test.go ├── csgo_valve_match730_003408404295698088038_1541485657_202_mirage_test.go ├── csgo_valve_match730_003598554255364980910_1802085029_272_ancient_test.go ├── fake ├── fake_player.go └── fake_round.go └── testsutils └── get_demo_path.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Report a reproducible bug or regression 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | This form is only for submitting bug reports. 8 | If you have a usage question or are unsure if it's a bug, please post a question in the 9 | [Discussions tab](https://github.com/akiver/cs-demo-analyzer/discussions). 10 | If it's a feature request please use the [feature request form](https://github.com/akiver/cs-demo-analyzer/issues/new?template=feature_requset.yml). 11 | 12 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 13 | - [Open issues](https://github.com/akiver/cs-demo-analyzer/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) 14 | - [Closed issues](https://github.com/akiver/cs-demo-analyzer/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed) 15 | - [Discussions tab](https://github.com/akiver/cs-demo-analyzer/discussions) 16 | 17 | If an issue already exists do not create a new one, and instead 👍 upvote the existing one. 18 | - type: textarea 19 | id: description 20 | attributes: 21 | label: Describe the bug 22 | description: Provide a clear and concise description of the issue you are running into. 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: demo-links 28 | attributes: 29 | label: Demo links 30 | description: | 31 | A link to download the demo(s) affected by this issue. 32 | placeholder: | 33 | e.g. Dropbox, Google Drive, Mega, Azure etc. 34 | 35 | - type: textarea 36 | id: steps 37 | attributes: 38 | label: Steps to reproduce 39 | description: | 40 | A step-by-step description of how to reproduce the issue. 41 | placeholder: | 42 | 1. Run: ./csda -demo-path myDemo.dem 43 | 2. Open the JSON file 44 | 3. Third step 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: expected 50 | attributes: 51 | label: Expected behavior 52 | description: Provide a clear and concise description of what you expected to happen. 53 | placeholder: | 54 | I would expected ___ but i am seeing ___ 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: code 60 | attributes: 61 | label: Code snippet 62 | description: | 63 | If applicable, paste a code snippet that can be used to reproduce the issue. 64 | 65 | - type: textarea 66 | id: environment 67 | attributes: 68 | label: Environment information 69 | placeholder: | 70 | - Version: xxx 71 | - OS: xxx 72 | validations: 73 | required: true 74 | 75 | - type: textarea 76 | id: additional 77 | attributes: 78 | label: Additional context 79 | description: Add extra information about the issue here. 80 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question 4 | url: https://github.com/akiver/cs-demo-analyzer/discussions 5 | about: Ask and answer questions here 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: '🚀 Enhancement or feature request' 2 | description: Suggest an idea 3 | labels: ['feature request'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## Before creating a feature request make sure that this hasn't been [requested before](https://github.com/akiver/cs-demo-analyzer/issues?q=is%3Aissue+label%3Afeateure-request). 9 | 10 | - type: textarea 11 | id: feature-description 12 | attributes: 13 | label: | 14 | What problem is this solving 15 | description: | 16 | A clear and concise description of what the problem is. 17 | placeholder: | 18 | When I [...] I want to [...] so that I [...] 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: proposed-solution 24 | attributes: 25 | label: Proposed solution 26 | description: | 27 | A clear and concise description of what you would like to happen to solve the problem. 28 | It can be technical or anything else. 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: alternative 34 | attributes: 35 | label: | 36 | Describe alternatives you've considered 37 | description: | 38 | A clear and concise description of any alternative solutions you've considered. 39 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'README.md' 9 | pull_request: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - 'README.md' 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Go 24 | uses: actions/setup-go@v4 25 | with: 26 | go-version-file: 'go.mod' 27 | 28 | - name: Download demos cache file 29 | run: curl -L -o demos.txt https://gitlab.com/akiver/cs-demos/-/raw/main/demos.txt 30 | 31 | - name: Restore demos cache 32 | uses: actions/cache@v4 33 | id: demos-cache 34 | with: 35 | path: cs-demos 36 | key: demos-${{ hashFiles('demos.txt') }} 37 | 38 | - name: Download demos 39 | if: steps.demos-cache.outputs.cache-hit != 'true' 40 | run: ./download-demos.sh 41 | 42 | - name: Test 43 | run: go test ./tests 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish new release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release-type: 7 | type: choice 8 | description: Select the release type 9 | required: true 10 | options: 11 | - patch 12 | - minor 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: Setup Go 28 | uses: actions/setup-go@v4 29 | with: 30 | go-version-file: 'go.mod' 31 | 32 | - name: Publish 33 | run: | 34 | make publish-${{ github.event.inputs.release-type }} 35 | env: 36 | NPM_EMAIL: ${{ secrets.NPM_EMAIL }} 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | 39 | - name: Get last git tag 40 | id: git-tag 41 | run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" 42 | 43 | - name: Generate zip archives 44 | run: | 45 | cd bin/darwin-x64 && zip darwin-x64.zip * && cd - 46 | cd bin/darwin-arm64 && zip darwin-arm64.zip * && cd - 47 | cd bin/linux-x64 && zip linux-x64.zip * && cd - 48 | cd bin/linux-arm64 && zip linux-arm64.zip * && cd - 49 | cd bin/windows-x64 && zip windows-x64.zip * && cd - 50 | 51 | - name: Create GitHub release 52 | uses: ncipollo/release-action@v1 53 | with: 54 | artifacts: 'bin/**/*.zip' 55 | tag: ${{ steps.git-tag.outputs.tag }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store 2 | dist 3 | bin 4 | node_modules 5 | *.exe 6 | *.zip 7 | *.csv 8 | *.info 9 | /*.json 10 | /debug/*.json 11 | .vscode/launch.json 12 | -------------------------------------------------------------------------------- /.vscode/launch.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "${workspaceRoot}/cmd/cli", 10 | "cwd": "${workspaceFolder}", 11 | "args": ["-demo-path=./debug/demo.dem", "-output=.", "-format=json", "-positions=false"] 12 | }, 13 | { 14 | "name": "Test Current File", 15 | "type": "go", 16 | "request": "launch", 17 | "mode": "test", 18 | "program": "${file}", 19 | "args": [] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-present AkiVer 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Strip debug info 2 | GO_FLAGS += "-ldflags=-s -w" 3 | # Avoid embedding the build path in the executable for more reproducible builds 4 | GO_FLAGS += -trimpath 5 | BINARY_NAME=csda 6 | CLI_PATH = ./cmd/cli 7 | 8 | .DEFAULT_GOAL := help 9 | 10 | OS = $(shell uname) 11 | ifneq (,$(findstring MSYS_NT,$(OS))) 12 | IS_WINDOWS=1 13 | endif 14 | 15 | build-unixlike: 16 | @test -n "$(GOOS)" || (echo "The environment variable GOOS must be provided" && false) 17 | @test -n "$(GOARCH)" || (echo "The environment variable GOARCH must be provided" && false) 18 | @test -n "$(BIN_DIR)" || (echo "The environment variable BIN_DIR must be provided" && false) 19 | CGO_ENABLED=0 GOOS="$(GOOS)" GOARCH="$(GOARCH)" go build $(GO_FLAGS) -o "$(BIN_DIR)/$(BINARY_NAME)" $(CLI_PATH) 20 | chmod +x "$(BIN_DIR)/$(BINARY_NAME)" 21 | 22 | build-darwin: ## Build for Darwin x64 23 | @"$(MAKE)" GOOS=darwin GOARCH=amd64 BIN_DIR=bin/darwin-x64 build-unixlike 24 | 25 | build-darwin-arm64: ## Build for Darwin arm64 26 | @"$(MAKE)" GOOS=darwin GOARCH=arm64 BIN_DIR=bin/darwin-arm64 build-unixlike 27 | 28 | build-linux: ## Build for Linux x64 29 | @"$(MAKE)" GOOS=linux GOARCH=amd64 BIN_DIR=bin/linux-x64 build-unixlike 30 | 31 | build-linux-arm64: ## Build for Linux arm64 32 | @"$(MAKE)" GOOS=linux GOARCH=arm64 BIN_DIR=bin/linux-arm64 build-unixlike 33 | 34 | build-windows: ## Build for Windows x64 35 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o bin/windows-x64/$(BINARY_NAME).exe $(CLI_PATH) 36 | 37 | build-js: ## Build the JS bundle 38 | @cd js && \ 39 | npm install && \ 40 | npm run build 41 | 42 | build-all: ## Run for all platform 43 | @"$(MAKE)" --no-print-directory -j4 \ 44 | build-darwin \ 45 | build-darwin-arm64 \ 46 | build-linux \ 47 | build-linux-arm64 \ 48 | build-windows \ 49 | build-js 50 | @cp -r ./bin/. ./js/dist/bin 51 | 52 | npm-publish: # Publish a new version of the JS package to npm 53 | @test -z $(IS_WINDOWS) || (echo "Publishing from a Windows machine is not allowed because chmod would not work for unix binaries" && false) 54 | @test -n "$(VERSION)" || (echo "The environment variable VERSION must be provided" && false) 55 | @test -n "$(NPM_EMAIL)" || (echo "The environment variable NPM_EMAIL must be provided" && false) 56 | @npm --version > /dev/null || (echo "The npm CLI must be installed to publish" && false) 57 | @echo "Checking for pending git changes..." && test -z "`git status --porcelain`" || \ 58 | (echo "Refusing to publish with these penging git changes:" && git status --porcelain && false) 59 | @echo "Checking for main branch..." && test "`git rev-parse --abbrev-ref HEAD`" = main || \ 60 | (echo "Refusing to publish from non-main branch `git rev-parse --abbrev-ref HEAD`" && false) 61 | @echo "Checking for unpushed commits..." && git fetch 62 | @test "`git cherry`" = "" || (echo "Refusing to publish with unpushed commits" && false) 63 | 64 | @"$(MAKE)" clean 65 | @"$(MAKE)" build-all 66 | git config --global user.name AkiVer 67 | git config --global user.email $(NPM_EMAIL) 68 | @cd js && \ 69 | npm version $(VERSION) --tag-version-prefix="" | awk '{print $$NF}' > /tmp/NEW_VERSION && \ 70 | git add package.json package-lock.json && \ 71 | git commit -m "chore: version `cat /tmp/NEW_VERSION`" && \ 72 | git tag v`cat /tmp/NEW_VERSION` 73 | 74 | @test -z "`git status --porcelain`" || (echo "Aborting because git is somehow unclean after a commit" && false) 75 | @cd js && \ 76 | npm publish --access public && \ 77 | git push origin main --tags 78 | 79 | publish-minor: ## Publish a minor version of the JS package 80 | @"$(MAKE)" VERSION=minor npm-publish 81 | 82 | publish-patch: ## Publish a patch version of the JS package 83 | @"$(MAKE)" VERSION=patch npm-publish 84 | 85 | test: ## Run all tests 86 | go test ./tests/ $(ARGS) 87 | 88 | test-verbose: ## Run tests in verbose 89 | @"$(MAKE)" --no-print-directory ARGS=-v test 90 | 91 | vet: ## Run go vet 92 | go vet ./cmd/... ./pkg/... 93 | 94 | clean: ## Clean up project files 95 | rm -rf bin 96 | rm -rf ./js/dist 97 | rm -f ./cs-demos/*.csv 98 | rm -f ./cs-demos/*.json 99 | 100 | help: 101 | @echo 'Targets:' 102 | @awk -F ':|##' '/^[^\t].+?:.*?##/ {printf "\033[36m %-20s\033[0m %s\n", $$1, $$NF}' $(MAKEFILE_LIST) 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CS Demo Analyzer 2 | 3 | A CLI to analyze and export CS2/CS:GO demos. 4 | 5 | ## Usage 6 | 7 | Ready-to-use binaries are available on the [releases page](https://github.com/akiver/cs-demo-analyzer/releases). 8 | 9 | ### Options 10 | 11 | ``` 12 | csda -help 13 | 14 | Usage of csda: 15 | -demo-path string 16 | Demo file path (mandatory) 17 | -format string 18 | Export format, valid values: [csv,json,csdm] (default "csv") 19 | -minify 20 | Minify JSON file, it has effect only when -format is set to json 21 | -output string 22 | Output folder or file path, must be a folder when exporting to CSV (mandatory) 23 | -positions 24 | Include entities (players, grenades...) positions (default false) 25 | -source string 26 | Force demo's source, valid values: [challengermode,ebot,esea,esl,esportal,faceit,fastcup,5eplay,perfectworld,popflash,valve] 27 | ``` 28 | 29 | ### Examples 30 | 31 | Export a demo into CSV files in the current folder. 32 | 33 | `csda -demo-path=myDemo.dem -output=.` 34 | 35 | Export a demo in a specific folder into a minified JSON file including entities positions. 36 | 37 | `csda -demo-path=/path/to/myDemo.dem -output=/path/to/folder -format=json -positions -minify` 38 | 39 | ## API 40 | 41 | ### GO API 42 | 43 | This API exposes functions to analyze/export a demo using the Go language. 44 | 45 | #### Analyze 46 | 47 | This function analyzes the demo located at the given path and returns a `Match`. 48 | 49 | ```go 50 | package main 51 | 52 | import ( 53 | "fmt" 54 | "os" 55 | 56 | "github.com/akiver/cs-demo-analyzer/pkg/api" 57 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 58 | ) 59 | 60 | func main() { 61 | match, err := api.AnalyzeDemo("./myDemo.dem", api.AnalyzeDemoOptions{ 62 | IncludePositions: true, 63 | Source: constants.DemoSourceValve, 64 | }) 65 | 66 | if err != nil { 67 | fmt.Fprintln(os.Stderr, err) 68 | os.Exit(1) 69 | } 70 | 71 | for _, kill := range match.Kills { 72 | fmt.Printf("(%d): %s killed %s with %s\n", kill.Tick, kill.KillerName, kill.VictimName, kill.WeaponName) 73 | } 74 | } 75 | ``` 76 | 77 | #### Analyze and export 78 | 79 | This function analyzes and exports a demo into the given output path. 80 | 81 | ```go 82 | package main 83 | 84 | import ( 85 | "fmt" 86 | "os" 87 | "path/filepath" 88 | 89 | "github.com/akiver/cs-demo-analyzer/pkg/api" 90 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 91 | ) 92 | 93 | func main() { 94 | exePath, _ := os.Executable() 95 | outputPath := filepath.Dir(exePath) 96 | err := api.AnalyzeAndExportDemo("./myDemo.dem", outputPath, api.AnalyzeAndExportDemoOptions{ 97 | IncludePositions: false, 98 | Source: constants.DemoSourceValve, 99 | Format: constants.ExportFormatJSON, 100 | MinifyJSON: true, 101 | }) 102 | 103 | if err != nil { 104 | fmt.Fprintln(os.Stderr, err) 105 | os.Exit(1) 106 | } 107 | 108 | fmt.Println("Demo analyzed and exported in " + outputPath) 109 | } 110 | ``` 111 | 112 | ### CLI 113 | 114 | This API exposes the command-line interface. 115 | 116 | ```go 117 | package main 118 | 119 | import ( 120 | "os" 121 | 122 | "github.com/akiver/cs-demo-analyzer/pkg/cli" 123 | ) 124 | 125 | func main() { 126 | os.Exit(cli.Run(os.Args[1:])) 127 | } 128 | ``` 129 | 130 | ### Node.js API 131 | 132 | A Node.js module called `@akiver/cs-demo-analyzer` is available on NPM. 133 | It exposes a function that under the hood is a wrapper around the Go CLI. 134 | The module also exports TypeScript types and constants. 135 | 136 | ```js 137 | import { analyzeDemo, DemoSource, ExportFormat } from '@akiver/cs-demo-analyzer'; 138 | 139 | async function main() { 140 | await analyzeDemo({ 141 | demoPath: './myDemo.dem', 142 | outputFolderPath: '.', 143 | format: ExportFormat.JSON, 144 | source: DemoSource.Valve, 145 | analyzePositions: false, 146 | minify: false, 147 | onStderr: console.error, 148 | onStdout: console.log, 149 | onStart: () => { 150 | console.log('Starting!'); 151 | }, 152 | onEnd: () => { 153 | console.log('Done!'); 154 | }, 155 | }); 156 | } 157 | 158 | main(); 159 | ``` 160 | 161 | ## Developing 162 | 163 | ### Requirements 164 | 165 | - [Go](https://golang.org/dl/) 166 | - [Make](https://www.gnu.org/software/make/) 167 | 168 | ### Build 169 | 170 | #### Windows 171 | 172 | `make build-windows` 173 | 174 | #### macOS 175 | 176 | `make build-darwin` / `make build-darwin-arm64` 177 | 178 | #### Linux 179 | 180 | `make build-linux` / `make build-linux-arm64` 181 | 182 | ### Tests 183 | 184 | 1. `./download-demos.sh` it will download the demos used for the tests 185 | 2. `make test` 186 | 187 | ### VSCode debugger 188 | 189 | 1. Inside the `.vscode` folder, copy/paste the file `launch.template.json` and name it `launch.json` 190 | 2. Place a demo in the `debug` folder 191 | 3. Update the `-demo-path` argument to point to the demo you just placed 192 | 4. Adjust the other arguments as you wish 193 | 5. Start the debugger from VSCode 194 | 195 | ## Acknowledgements 196 | 197 | This project uses the demo parser [demoinfocs-golang](https://github.com/markus-wa/demoinfocs-golang) created by [@markus-wa](https://github.com/markus-wa) and maintained by him and [@akiver](https://github.com/akiver). 198 | 199 | ## License 200 | 201 | [MIT](https://github.com/akiver/cs-demo-analyzer/blob/main/LICENSE.md) 202 | -------------------------------------------------------------------------------- /cmd/cli/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/cli" 7 | ) 8 | 9 | func main() { 10 | os.Exit(cli.Run(os.Args[1:])) 11 | } 12 | -------------------------------------------------------------------------------- /cs-demos/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /debug/.gitignore: -------------------------------------------------------------------------------- 1 | *.dem 2 | *.csv 3 | *.json 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /download-demos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | mkdir -p cs-demos 6 | 7 | curl -s -L -o /tmp/demos.txt https://gitlab.com/akiver/cs-demos/-/raw/main/demos.txt 8 | files=$(cat /tmp/demos.txt) 9 | 10 | for file in $files; do 11 | demoPath="cs-demos/$file" 12 | if [ -f $demoPath ]; then 13 | continue 14 | fi 15 | 16 | echo "Downloading demo $demoPath" 17 | curl -s -L -o $demoPath https://gitlab.com/akiver/cs-demos/-/raw/main/$file --create-dirs 18 | done 19 | 20 | echo 'Done' 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/akiver/cs-demo-analyzer 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 7 | github.com/markus-wa/demoinfocs-golang/v4 v4.3.4 8 | github.com/markus-wa/gobitread v0.2.4 9 | github.com/pkg/errors v0.9.1 10 | google.golang.org/protobuf v1.36.6 11 | ) 12 | 13 | require ( 14 | github.com/golang/snappy v1.0.0 // indirect 15 | github.com/markus-wa/go-unassert v0.1.3 // indirect 16 | github.com/markus-wa/godispatch v1.4.1 // indirect 17 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7 // indirect 18 | github.com/markus-wa/quickhull-go/v2 v2.2.0 // indirect 19 | github.com/oklog/ulid/v2 v2.1.0 // indirect 20 | ) 21 | 22 | replace github.com/markus-wa/demoinfocs-golang/v4 v4.3.4 => github.com/markus-wa/demoinfocs-golang/v4 v4.3.4-0.20250519000310-e55c4eaa5aaf 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/golang/geo v0.0.0-20180826223333-635502111454/go.mod h1:vgWZ7cu0fq0KY3PpEHsocXOWJpRtkcbKemU4IUw0M60= 5 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 h1:HeOFbnyPys/vx/t+d4fwZM782mnjRVtbjxVkDittTUs= 6 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= 7 | github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 8 | github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 9 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 10 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 11 | github.com/markus-wa/demoinfocs-golang/v4 v4.3.4-0.20250519000310-e55c4eaa5aaf h1:n8ZdbwRKRP0O7WEtPLx5XoRLZxC8nqyWHFL1ojAthTo= 12 | github.com/markus-wa/demoinfocs-golang/v4 v4.3.4-0.20250519000310-e55c4eaa5aaf/go.mod h1:SfgbMznZREy98M7EjzkIPxEpZPVpbX/f9tVGSTJF3WU= 13 | github.com/markus-wa/go-unassert v0.1.3 h1:4N2fPLUS3929Rmkv94jbWskjsLiyNT2yQpCulTFFWfM= 14 | github.com/markus-wa/go-unassert v0.1.3/go.mod h1:/pqt7a0LRmdsRNYQ2nU3SGrXfw3bLXrvIkakY/6jpPY= 15 | github.com/markus-wa/gobitread v0.2.4 h1:BDr3dZnsqntDD4D8E7DzhkQlASIkQdfxCXLhWcI2K5A= 16 | github.com/markus-wa/gobitread v0.2.4/go.mod h1:PcWXMH4gx7o2CKslbkFkLyJB/aHW7JVRG3MRZe3PINg= 17 | github.com/markus-wa/godispatch v1.4.1 h1:Cdff5x33ShuX3sDmUbYWejk7tOuoHErFYMhUc2h7sLc= 18 | github.com/markus-wa/godispatch v1.4.1/go.mod h1:tk8L0yzLO4oAcFwM2sABMge0HRDJMdE8E7xm4gK/+xM= 19 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7 h1:aR9pvnlnBxifXBmzidpAiq2prLSGlkhE904qnk2sCz4= 20 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7/go.mod h1:JIsht5Oa9P50VnGJTvH2a6nkOqDFJbUeU1YRZYvdplw= 21 | github.com/markus-wa/quickhull-go/v2 v2.2.0 h1:rB99NLYeUHoZQ/aNRcGOGqjNBGmrOaRxdtqTnsTUPTA= 22 | github.com/markus-wa/quickhull-go/v2 v2.2.0/go.mod h1:EuLMucfr4B+62eipXm335hOs23LTnO62W7Psn3qvU2k= 23 | github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= 24 | github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 25 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 26 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 27 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 31 | github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 34 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 35 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 37 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 39 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 40 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 41 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 | -------------------------------------------------------------------------------- /internal/bitread/bitread.go: -------------------------------------------------------------------------------- 1 | // Copy paste of https://github.com/markus-wa/demoinfocs-golang/blob/master/internal/bitread/bitread.go 2 | // We need it to read Source 1 demos header and for Source 2 demos the CDemoFileHeader proto message. 3 | package bitread 4 | 5 | import ( 6 | "io" 7 | "math" 8 | "sync" 9 | 10 | bitread "github.com/markus-wa/gobitread" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | smallBuffer = 512 16 | largeBuffer = 1024 * 128 17 | maxVarInt32Bytes = 5 18 | maxVarintBytes = 10 19 | ) 20 | 21 | // BitReader wraps github.com/markus-wa/gobitread.BitReader and provides additional functionality specific to CS:GO demos. 22 | type BitReader struct { 23 | bitread.BitReader 24 | buffer *[]byte 25 | } 26 | 27 | // ReadString reads a variable length string. 28 | func (r *BitReader) ReadString() string { 29 | // Valve also uses this sooo 30 | const valveMaxStringLength = 4096 31 | return r.readStringLimited(valveMaxStringLength, false) 32 | } 33 | 34 | func (r *BitReader) readStringLimited(limit int, endOnNewLine bool) string { 35 | const minStringBufferLength = 256 36 | result := make([]byte, 0, minStringBufferLength) 37 | 38 | for i := 0; i < limit; i++ { 39 | b := r.ReadSingleByte() 40 | if b == 0 || (endOnNewLine && b == '\n') { 41 | break 42 | } 43 | 44 | result = append(result, b) 45 | } 46 | 47 | return string(result) 48 | } 49 | 50 | // ReadFloat reads a 32-bit float. Wraps ReadInt(). 51 | func (r *BitReader) ReadFloat() float32 { 52 | return math.Float32frombits(uint32(r.ReadInt(32))) 53 | } 54 | 55 | // ReadVarInt32 reads a variable size unsigned int (max 32-bit). 56 | func (r *BitReader) ReadVarInt32() uint32 { 57 | var ( 58 | res uint32 59 | b uint32 = 0x80 60 | ) 61 | 62 | for count := uint(0); b&0x80 != 0 && count != maxVarInt32Bytes; count++ { 63 | b = uint32(r.ReadSingleByte()) 64 | res |= (b & 0x7f) << (7 * count) 65 | } 66 | 67 | return res 68 | } 69 | 70 | // ReadVarInt64 reads a variable size unsigned int (max 64-bit). 71 | func (r *BitReader) ReadVarInt64() uint64 { 72 | var ( 73 | res uint64 74 | b uint64 = 0x80 75 | ) 76 | 77 | for count := uint(0); b&0x80 != 0 && count != maxVarintBytes; count++ { 78 | b = uint64(r.ReadSingleByte()) 79 | res |= (b & 0x7f) << (7 * count) 80 | } 81 | 82 | return res 83 | } 84 | 85 | // ReadSignedVarInt32 reads a variable size signed int (max 32-bit). 86 | func (r *BitReader) ReadSignedVarInt32() int32 { 87 | res := r.ReadVarInt32() 88 | return int32((res >> 1) ^ -(res & 1)) 89 | } 90 | 91 | // ReadSignedVarInt64 reads a variable size signed int (max 64-bit). 92 | func (r *BitReader) ReadSignedVarInt64() int64 { 93 | res := r.ReadVarInt64() 94 | return int64((res >> 1) ^ -(res & 1)) 95 | } 96 | 97 | // ReadUBitInt reads some kind of variable size uint. 98 | // Honestly, not quite sure how it works. 99 | func (r *BitReader) ReadUBitInt() uint { 100 | res := r.ReadInt(6) 101 | switch res & (16 | 32) { 102 | case 16: 103 | res = (res & 15) | (r.ReadInt(4) << 4) 104 | case 32: 105 | res = (res & 15) | (r.ReadInt(8) << 4) 106 | case 48: 107 | res = (res & 15) | (r.ReadInt(32-4) << 4) 108 | } 109 | 110 | return res 111 | } 112 | 113 | var bitReaderPool = sync.Pool{ 114 | New: func() any { 115 | return new(BitReader) 116 | }, 117 | } 118 | 119 | // Pool puts the BitReader into a pool for future use. 120 | // Pooling BitReaders improves performance by minimizing the amount newly allocated readers. 121 | func (r *BitReader) Pool() error { 122 | err := r.Close() 123 | if err != nil { 124 | return errors.Wrap(err, "failed to close BitReader before pooling") 125 | } 126 | 127 | if len(*r.buffer) == smallBuffer { 128 | smallBufferPool.Put(r.buffer) 129 | } 130 | 131 | r.buffer = nil 132 | 133 | bitReaderPool.Put(r) 134 | 135 | return nil 136 | } 137 | 138 | func newBitReader(underlying io.Reader, buffer *[]byte) *BitReader { 139 | br := bitReaderPool.Get().(*BitReader) 140 | br.buffer = buffer 141 | br.OpenWithBuffer(underlying, *buffer) 142 | 143 | return br 144 | } 145 | 146 | var smallBufferPool = sync.Pool{ 147 | New: func() any { 148 | b := make([]byte, smallBuffer) 149 | return &b 150 | }, 151 | } 152 | 153 | // NewSmallBitReader returns a BitReader with a small buffer, suitable for short streams. 154 | func NewSmallBitReader(underlying io.Reader) *BitReader { 155 | return newBitReader(underlying, smallBufferPool.Get().(*[]byte)) 156 | } 157 | 158 | // NewLargeBitReader returns a BitReader with a large buffer, suitable for long streams (main demo file). 159 | func NewLargeBitReader(underlying io.Reader) *BitReader { 160 | b := make([]byte, largeBuffer) 161 | return newBitReader(underlying, &b) 162 | } 163 | -------------------------------------------------------------------------------- /internal/converters/converters.go: -------------------------------------------------------------------------------- 1 | package converters 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 9 | ) 10 | 11 | func IntToString(value int) string { 12 | return strconv.Itoa(value) 13 | } 14 | 15 | func Float32ToString(value float32) string { 16 | return fmt.Sprintf("%f", value) 17 | } 18 | 19 | func Float64ToString(value float64) string { 20 | return fmt.Sprintf("%f", value) 21 | } 22 | 23 | func Int64ToString(value int64) string { 24 | return strconv.FormatInt(value, 10) 25 | } 26 | 27 | func Uint32ToString(value uint32) string { 28 | return fmt.Sprint(value) 29 | } 30 | 31 | func Uint64ToString(value uint64) string { 32 | return strconv.FormatUint(value, 10) 33 | } 34 | 35 | func BoolToString(value bool) string { 36 | if value { 37 | return "1" 38 | } 39 | 40 | return "0" 41 | } 42 | 43 | func ByteToString(value byte) string { 44 | return fmt.Sprintf("%d", value) 45 | } 46 | 47 | func TeamToString(value common.Team) string { 48 | return ByteToString(byte(value)) 49 | } 50 | 51 | func HitgroupToString(value events.HitGroup) string { 52 | return ByteToString(byte(value)) 53 | } 54 | 55 | func RoundEndReasonToString(value events.RoundEndReason) string { 56 | return ByteToString(byte(value)) 57 | } 58 | 59 | func ColorToString(color common.Color) string { 60 | return IntToString(int(color)) 61 | } 62 | 63 | func StringToInt(value string) int { 64 | valueAsInt, err := strconv.Atoi(value) 65 | if err == nil { 66 | return valueAsInt 67 | } 68 | 69 | return 0 70 | } 71 | 72 | func BombsiteToString(bombSite events.Bombsite) string { 73 | if bombSite == 0 { 74 | return "Unknown" 75 | } 76 | 77 | return string(bombSite) 78 | } 79 | -------------------------------------------------------------------------------- /internal/csv/csv.go: -------------------------------------------------------------------------------- 1 | package csv 2 | 3 | import ( 4 | "encoding/csv" 5 | "log" 6 | "os" 7 | ) 8 | 9 | func WriteLinesIntoCsvFile(csvFilePath string, lines [][]string) { 10 | file, err := os.Create(csvFilePath) 11 | if err != nil { 12 | log.Fatal("Error creating csv file", csvFilePath, err) 13 | } 14 | defer file.Close() 15 | 16 | writer := csv.NewWriter(file) 17 | if err := writer.WriteAll(lines); err != nil { 18 | log.Fatal("Cannot write CSV file", csvFilePath, err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/demo/sharecode.go: -------------------------------------------------------------------------------- 1 | package demo 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "math/big" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | dictionary = "ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789" 12 | ) 13 | 14 | type MatchInformation struct { 15 | MatchId uint64 16 | ReservationId uint64 17 | TvPort uint32 18 | } 19 | 20 | func bytesToShareCode(bytes []byte) string { 21 | dictionaryLength := big.NewInt(int64(len(dictionary))) 22 | total := new(big.Int).SetBytes(bytes) 23 | var builder strings.Builder 24 | builder.Grow(29) // "CSGO-" + 5*5 chars + 4*"-" 25 | 26 | for i := 0; i < 25; i++ { 27 | remainder := new(big.Int) 28 | total.DivMod(total, dictionaryLength, remainder) 29 | builder.WriteByte(dictionary[remainder.Int64()]) 30 | } 31 | 32 | str := builder.String() 33 | 34 | return fmt.Sprintf("CSGO-%s-%s-%s-%s-%s", str[0:5], str[5:10], str[10:15], str[15:20], str[20:25]) 35 | } 36 | 37 | func encodeMatchShareCode(match MatchInformation) string { 38 | bytes := make([]byte, 18) 39 | 40 | binary.LittleEndian.PutUint64(bytes[0:8], match.MatchId) 41 | binary.LittleEndian.PutUint64(bytes[8:16], match.ReservationId) 42 | binary.LittleEndian.PutUint16(bytes[16:18], uint16(match.TvPort)) 43 | 44 | return bytesToShareCode(bytes) 45 | } 46 | -------------------------------------------------------------------------------- /internal/demo/sharecode_test.go: -------------------------------------------------------------------------------- 1 | package demo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type Sample struct { 8 | shareCode string 9 | match MatchInformation 10 | } 11 | 12 | func TestEncodeMatchShareCode(t *testing.T) { 13 | matchSamples := []Sample{ 14 | { 15 | shareCode: "CSGO-L9spZ-ihuov-cyhtE-kxbqa-FkBAA", 16 | match: MatchInformation{ 17 | MatchId: 3400360672356205056, 18 | ReservationId: 3400367402569957763, 19 | TvPort: 9725, 20 | }, 21 | }, 22 | { 23 | shareCode: "CSGO-GADqf-jjyJ8-cSP2r-smZRo-TO2xK", 24 | match: MatchInformation{ 25 | MatchId: 3230642215713767580, 26 | ReservationId: 3230647599455273103, 27 | TvPort: 55788, 28 | }, 29 | }, 30 | { 31 | shareCode: "CSGO-bPQEz-PrYTq-u5w8E-ZbUy7-ZeQ3A", 32 | match: MatchInformation{ 33 | MatchId: 3325408798641750542, 34 | ReservationId: 3325410334092558852, 35 | TvPort: 240, 36 | }, 37 | }, 38 | { 39 | shareCode: "CSGO-wBrm6-7fkM6-AzBC5-u6GmR-iHLHA", 40 | match: MatchInformation{ 41 | MatchId: 3302232779302895618, 42 | ReservationId: 3302241568953467250, 43 | TvPort: 3085, 44 | }, 45 | }, 46 | { 47 | shareCode: "CSGO-TKDTJ-YrAXs-sDNfL-HOuKO-i84VH", 48 | match: MatchInformation{ 49 | MatchId: 3402250361329680757, 50 | ReservationId: 3402250801563828781, 51 | TvPort: 61630, 52 | }, 53 | }, 54 | { 55 | shareCode: "CSGO-p4X9o-3Mfut-tpe5y-J8K6f-mj5ZJ", 56 | match: MatchInformation{ 57 | MatchId: 3402249502336221574, 58 | ReservationId: 3402252092201501292, 59 | TvPort: 14119, 60 | }, 61 | }, 62 | } 63 | 64 | for _, sample := range matchSamples { 65 | shareCode := encodeMatchShareCode(sample.match) 66 | if shareCode != sample.shareCode { 67 | t.Errorf("Expected share code %s, got %s", sample.shareCode, shareCode) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/filepath/filepath.go: -------------------------------------------------------------------------------- 1 | package filepath 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func GetFileNameWithoutExtension(filePath string) string { 10 | fileNameWithExtension := filepath.Base(filePath) 11 | fileNameWithoutExtension := strings.TrimSuffix(fileNameWithExtension, filepath.Ext(fileNameWithExtension)) 12 | return fileNameWithoutExtension 13 | } 14 | 15 | func GetAbsoluteFilePath(filePath string) string { 16 | absolutePath, err := filepath.Abs(filePath) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | return absolutePath 21 | } 22 | -------------------------------------------------------------------------------- /internal/math/math.go: -------------------------------------------------------------------------------- 1 | package math 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/golang/geo/r3" 7 | ) 8 | 9 | // Return the distance between two vectors in meters. 10 | // https://developer.valvesoftware.com/wiki/Dimensions 11 | // 1 unit = 0.75 inch = 19.05mm = 0.01905m 12 | func GetDistanceBetweenVectors(vectorA r3.Vector, vectorB r3.Vector) float64 { 13 | return math.Sqrt(math.Pow(vectorA.X-vectorB.X, 2)+math.Pow(vectorA.Y-vectorB.Y, 2)+math.Pow(vectorA.Z-vectorB.Z, 2)) * 0.01905 14 | } 15 | 16 | func Max(a int, b int) int { 17 | if a > b { 18 | return a 19 | } 20 | 21 | return b 22 | } 23 | -------------------------------------------------------------------------------- /internal/slice/slice.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | func Contains[T comparable](collection []T, element T) bool { 4 | for _, item := range collection { 5 | if item == element { 6 | return true 7 | } 8 | } 9 | 10 | return false 11 | } 12 | 13 | func AppendIfNotInSlice[T comparable](slice []T, value T) []T { 14 | if !Contains(slice, value) { 15 | slice = append(slice, value) 16 | } 17 | 18 | return slice 19 | } 20 | 21 | type stringable interface { 22 | String() string 23 | } 24 | 25 | func ToStrings[T stringable](slice []T) []string { 26 | stringSlice := make([]string, len(slice)) 27 | for index, value := range slice { 28 | stringSlice[index] = value.String() 29 | } 30 | 31 | return stringSlice 32 | } 33 | 34 | func Filter[T comparable](s []T, predicate func(item T, index int) bool) []T { 35 | result := make([]T, 0, len(s)) 36 | 37 | for i, item := range s { 38 | if predicate(item, i) { 39 | result = append(result, item) 40 | } 41 | } 42 | 43 | return result 44 | } 45 | 46 | func Find[T comparable](collection []T, predicate func(item T) bool) (T, bool) { 47 | for i := range collection { 48 | if predicate(collection[i]) { 49 | return collection[i], true 50 | } 51 | } 52 | 53 | var result T 54 | return result, false 55 | } 56 | -------------------------------------------------------------------------------- /internal/strings/strings.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | "unicode/utf8" 7 | ) 8 | 9 | func RemoveInvisibleChars(s string) string { 10 | return strings.Map(func(r rune) rune { 11 | if unicode.IsGraphic(r) { 12 | return r 13 | } 14 | 15 | return -1 16 | }, s) 17 | } 18 | 19 | func RemoveInvalidUTF8Sequences(s string) string { 20 | return strings.ToValidUTF8(s, "") 21 | } 22 | 23 | // Replaces invalid UTF-8 byte sequences from a string by their corresponding Unicode replacement character. 24 | // For example: "🚩Test\xf6Test界" will become "🚩TestöTest界". 25 | func ReplaceUTF8ByteSequences(s string) string { 26 | if utf8.ValidString(s) { 27 | return s 28 | } 29 | 30 | var b strings.Builder 31 | for i, r := range s { 32 | r, _ := utf8.DecodeRuneInString(string(r)) 33 | if r == utf8.RuneError { 34 | b.WriteString(string(s[i])) 35 | } else { 36 | b.WriteRune(r) 37 | } 38 | } 39 | 40 | return b.String() 41 | } 42 | -------------------------------------------------------------------------------- /js/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@akiver/cs-demo-analyzer", 3 | "version": "1.7.2", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@akiver/cs-demo-analyzer", 9 | "version": "1.7.2", 10 | "cpu": [ 11 | "x64", 12 | "arm64" 13 | ], 14 | "license": "MIT", 15 | "os": [ 16 | "darwin", 17 | "linux", 18 | "win32" 19 | ], 20 | "bin": { 21 | "csda": "dist/cli.js" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "22.13.9", 25 | "prettier": "3.5.3", 26 | "typescript": "5.8.2" 27 | } 28 | }, 29 | "node_modules/@types/node": { 30 | "version": "22.13.9", 31 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", 32 | "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", 33 | "dev": true, 34 | "license": "MIT", 35 | "dependencies": { 36 | "undici-types": "~6.20.0" 37 | } 38 | }, 39 | "node_modules/prettier": { 40 | "version": "3.5.3", 41 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 42 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 43 | "dev": true, 44 | "license": "MIT", 45 | "bin": { 46 | "prettier": "bin/prettier.cjs" 47 | }, 48 | "engines": { 49 | "node": ">=14" 50 | }, 51 | "funding": { 52 | "url": "https://github.com/prettier/prettier?sponsor=1" 53 | } 54 | }, 55 | "node_modules/typescript": { 56 | "version": "5.8.2", 57 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", 58 | "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", 59 | "dev": true, 60 | "license": "Apache-2.0", 61 | "bin": { 62 | "tsc": "bin/tsc", 63 | "tsserver": "bin/tsserver" 64 | }, 65 | "engines": { 66 | "node": ">=14.17" 67 | } 68 | }, 69 | "node_modules/undici-types": { 70 | "version": "6.20.0", 71 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 72 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 73 | "dev": true, 74 | "license": "MIT" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@akiver/cs-demo-analyzer", 3 | "version": "1.7.2", 4 | "description": "Analyze and extract data from Counter-Strike demos.", 5 | "repository": "https://github.com/akiver/cs-demo-analyzer", 6 | "main": "./dist/index.js", 7 | "bin": { 8 | "csda": "./dist/cli.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc && tsc --emitDeclarationOnly --declaration --removeComments false", 12 | "format": "prettier --write src/*.ts" 13 | }, 14 | "author": "AkiVer", 15 | "files": [ 16 | "dist" 17 | ], 18 | "license": "MIT", 19 | "os": [ 20 | "darwin", 21 | "linux", 22 | "win32" 23 | ], 24 | "cpu": [ 25 | "x64", 26 | "arm64" 27 | ], 28 | "keywords": [ 29 | "Counter-Strike", 30 | "CS", 31 | "CSGO", 32 | "CS2", 33 | "demos", 34 | "replays" 35 | ], 36 | "devDependencies": { 37 | "@types/node": "22.13.9", 38 | "prettier": "3.5.3", 39 | "typescript": "5.8.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /js/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { getBinaryPath } from './platform'; 3 | 4 | try { 5 | const binPath = getBinaryPath(); 6 | require('child_process').execFileSync(binPath, process.argv.slice(2), { stdio: 'inherit' }); 7 | } catch (error) { 8 | if (error && typeof error === 'object' && 'status' in error && typeof error.status === 'number') { 9 | process.exit(error.status); 10 | } 11 | 12 | throw error; 13 | } 14 | -------------------------------------------------------------------------------- /js/src/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import fs from 'node:fs/promises'; 3 | import { getBinaryPath } from './platform'; 4 | import { DemoSource, ExportFormat } from './constants'; 5 | 6 | export type Options = { 7 | demoPath: string; 8 | outputFolderPath: string; 9 | format: ExportFormat; 10 | source?: DemoSource; 11 | analyzePositions?: boolean; 12 | minify?: boolean; // JSON only 13 | onStart?: (command: string) => void; 14 | onStdout?: (data: string) => void; 15 | onStderr?: (data: string) => void; 16 | onEnd?: (exitCode: number) => void; 17 | executablePath?: string; 18 | }; 19 | 20 | export async function analyzeDemo({ 21 | demoPath, 22 | outputFolderPath, 23 | format, 24 | source, 25 | analyzePositions, 26 | minify, 27 | onStart, 28 | onStdout, 29 | onStderr, 30 | onEnd, 31 | executablePath, 32 | }: Options): Promise { 33 | await fs.mkdir(outputFolderPath, { recursive: true }); 34 | 35 | return new Promise((resolve, reject) => { 36 | const binPath = executablePath ?? getBinaryPath(); 37 | const args: string[] = [ 38 | `"${binPath}"`, 39 | `-demo-path="${demoPath}"`, 40 | `-output="${outputFolderPath}"`, 41 | `-format="${format}"`, 42 | ]; 43 | if (source) { 44 | args.push(`-source="${source}"`); 45 | } 46 | if (analyzePositions) { 47 | args.push(`-positions="${analyzePositions}"`); 48 | } 49 | if (minify) { 50 | args.push('-minify'); 51 | } 52 | const command = args.join(' '); 53 | if (onStart) { 54 | onStart(command); 55 | } 56 | 57 | const child = exec(command, { windowsHide: true, maxBuffer: undefined }); 58 | if (onStdout) { 59 | child.stdout?.on('data', (data: string) => { 60 | onStdout(data); 61 | }); 62 | } 63 | 64 | if (onStderr) { 65 | child.stderr?.on('data', (data: string) => { 66 | onStderr(data); 67 | }); 68 | } 69 | 70 | child.on('exit', (code: number) => { 71 | if (onEnd) { 72 | onEnd(code); 73 | } 74 | if (code === 0) { 75 | resolve(); 76 | } else { 77 | reject(); 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | export * from './constants'; 84 | -------------------------------------------------------------------------------- /js/src/platform.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | function getBinarySubpath() { 4 | const supportedPlatforms: Record = { 5 | 'darwin-x64': 'bin/darwin-x64/csda', 6 | 'darwin-arm64': 'bin/darwin-arm64/csda', 7 | 'linux-x64': 'bin/linux-x64/csda', 8 | 'linux-arm64': 'bin/linux-arm64/csda', 9 | 'win32-x64': 'bin/windows-x64/csda.exe', 10 | }; 11 | 12 | const platformKey = `${process.platform}-${process.arch}`; 13 | if (!supportedPlatforms[platformKey]) { 14 | throw new Error(`Unsupported platform: ${platformKey}`); 15 | } 16 | 17 | return supportedPlatforms[platformKey]; 18 | } 19 | 20 | export function getBinaryPath() { 21 | return path.join(__dirname, getBinarySubpath()); 22 | } 23 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "outDir": "dist", 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /pkg/api/5eplay.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 5 | st "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 6 | ) 7 | 8 | func createFiveEPlayAnalyzer(analyzer *Analyzer) { 9 | parser := analyzer.parser 10 | isRestarting := false 11 | isMatchStarted := false 12 | analyzer.matchStarted = func() bool { 13 | return isMatchStarted 14 | } 15 | 16 | parser.RegisterEventHandler(func(event events.IsWarmupPeriodChanged) { 17 | // Match start detection - if there is a knife round the detection will be done with the m_bGameRestart prop. 18 | if event.OldIsWarmupPeriod && !event.NewIsWarmupPeriod { 19 | isKnifeRound := false 20 | for _, player := range analyzer.parser.GameState().Participants().Playing() { 21 | if player.Money() == 0 { 22 | isKnifeRound = true 23 | break 24 | } 25 | } 26 | 27 | if isKnifeRound { 28 | return 29 | } 30 | 31 | isMatchStarted = true 32 | analyzer.processMatchStart() 33 | } 34 | }) 35 | 36 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 37 | parser.ServerClasses().FindByName("CCSGameRulesProxy").OnEntityCreated(func(entity st.Entity) { 38 | // Match start detection when there was a knife round - the game really starts after a restart. 39 | var gameRestartProp st.Property 40 | if analyzer.isSource2 { 41 | gameRestartProp = entity.Property("m_pGameRules.m_bGameRestart") 42 | } else { 43 | gameRestartProp = entity.Property("cs_gamerules_data.m_bGameRestart") 44 | } 45 | 46 | gameRestartProp.OnUpdate(func(val st.PropertyValue) { 47 | newIsRestarting := val.BoolVal() 48 | if isRestarting && !newIsRestarting { 49 | isMatchStarted = true 50 | analyzer.processMatchStart() 51 | } 52 | isRestarting = newIsRestarting 53 | }) 54 | }) 55 | }) 56 | 57 | parser.RegisterEventHandler(func(event events.GameHalfEnded) { 58 | analyzer.isFirstRoundOfHalf = true 59 | }) 60 | 61 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 62 | 63 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 64 | 65 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 66 | 67 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 68 | // Match end detection 69 | if isMatchStarted && !event.NewIsStarted { 70 | isMatchStarted = false 71 | } 72 | }) 73 | 74 | parser.RegisterEventHandler(func(event events.AnnouncementWinPanelMatch) { 75 | analyzer.updatePlayersScores() 76 | isMatchStarted = false 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /pkg/api/bomb_defuse_start.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type BombDefuseStart struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | PlanterSteamID64 uint64 `json:"defuserSteamId"` 12 | PlanterName string `json:"defuserName"` 13 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 14 | X float64 `json:"x"` 15 | Y float64 `json:"y"` 16 | Z float64 `json:"z"` 17 | } 18 | 19 | func newBombDefuseStart(analyzer *Analyzer, player *common.Player) *BombDefuseStart { 20 | parser := analyzer.parser 21 | 22 | return &BombDefuseStart{ 23 | Frame: parser.CurrentFrame(), 24 | Tick: analyzer.currentTick(), 25 | RoundNumber: analyzer.currentRound.Number, 26 | PlanterName: player.Name, 27 | PlanterSteamID64: player.SteamID64, 28 | IsPlayerControllingBot: player.IsControllingBot(), 29 | X: player.LastAlivePosition.X, 30 | Y: player.LastAlivePosition.Y, 31 | Z: player.LastAlivePosition.Z, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/api/bomb_defused.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/converters" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 7 | ) 8 | 9 | type BombDefused struct { 10 | Frame int `json:"frame"` 11 | Tick int `json:"tick"` 12 | RoundNumber int `json:"roundNumber"` 13 | Site string `json:"site"` 14 | DefuserSteamID64 uint64 `json:"defuserSteamId"` 15 | DefuserName string `json:"defuserName"` 16 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 17 | X float64 `json:"x"` 18 | Y float64 `json:"y"` 19 | Z float64 `json:"z"` 20 | CounterTerroristAliveCount int `json:"counterTerroristAliveCount"` 21 | TerroristAliveCount int `json:"terroristAliveCount"` 22 | } 23 | 24 | func newBombDefused(analyzer *Analyzer, event events.BombDefused) *BombDefused { 25 | parser := analyzer.parser 26 | player := event.Player 27 | 28 | counterTerroristAliveCount := 0 29 | terroristAliveCount := 0 30 | for _, player := range parser.GameState().Participants().Playing() { 31 | if !player.IsAlive() { 32 | continue 33 | } 34 | if player.Team == common.TeamCounterTerrorists { 35 | counterTerroristAliveCount++ 36 | } else if player.Team == common.TeamTerrorists { 37 | terroristAliveCount++ 38 | } 39 | } 40 | 41 | return &BombDefused{ 42 | Frame: parser.CurrentFrame(), 43 | Tick: analyzer.currentTick(), 44 | RoundNumber: analyzer.currentRound.Number, 45 | DefuserName: player.Name, 46 | DefuserSteamID64: player.SteamID64, 47 | IsPlayerControllingBot: player.IsControllingBot(), 48 | Site: converters.BombsiteToString(event.BombEvent.Site), 49 | X: player.LastAlivePosition.X, 50 | Y: player.LastAlivePosition.Y, 51 | Z: player.LastAlivePosition.Z, 52 | CounterTerroristAliveCount: counterTerroristAliveCount, 53 | TerroristAliveCount: terroristAliveCount, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/api/bomb_exploded.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/converters" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | type BombExploded struct { 9 | Frame int `json:"frame"` 10 | Tick int `json:"tick"` 11 | RoundNumber int `json:"roundNumber"` 12 | Site string `json:"site"` 13 | PlanterSteamID64 uint64 `json:"defuserSteamId"` 14 | PlanterName string `json:"defuserName"` 15 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | } 20 | 21 | func newBombExploded(analyzer *Analyzer, event events.BombExplode) *BombExploded { 22 | parser := analyzer.parser 23 | player := event.Player 24 | 25 | return &BombExploded{ 26 | Frame: parser.CurrentFrame(), 27 | Tick: analyzer.currentTick(), 28 | RoundNumber: analyzer.currentRound.Number, 29 | PlanterName: player.Name, 30 | PlanterSteamID64: player.SteamID64, 31 | IsPlayerControllingBot: player.IsControllingBot(), 32 | Site: converters.BombsiteToString(event.BombEvent.Site), 33 | X: analyzer.bombPlantPosition.X, 34 | Y: analyzer.bombPlantPosition.Y, 35 | Z: analyzer.bombPlantPosition.Z, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/api/bomb_plant_start.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/converters" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | type BombPlantStart struct { 9 | Frame int `json:"frame"` 10 | Tick int `json:"tick"` 11 | RoundNumber int `json:"roundNumber"` 12 | Site string `json:"site"` 13 | PlanterSteamID64 uint64 `json:"defuserSteamId"` 14 | PlanterName string `json:"defuserName"` 15 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | } 20 | 21 | func newBombPlantStart(analyzer *Analyzer, event events.BombPlantBegin) *BombPlantStart { 22 | parser := analyzer.parser 23 | player := event.Player 24 | 25 | return &BombPlantStart{ 26 | Frame: parser.CurrentFrame(), 27 | Tick: analyzer.currentTick(), 28 | RoundNumber: analyzer.currentRound.Number, 29 | PlanterName: player.Name, 30 | PlanterSteamID64: player.SteamID64, 31 | IsPlayerControllingBot: player.IsControllingBot(), 32 | Site: converters.BombsiteToString(event.BombEvent.Site), 33 | X: player.LastAlivePosition.X, 34 | Y: player.LastAlivePosition.Y, 35 | Z: player.LastAlivePosition.Z, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/api/bomb_planted.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/converters" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | type BombPlanted struct { 9 | Frame int `json:"frame"` 10 | Tick int `json:"tick"` 11 | RoundNumber int `json:"roundNumber"` 12 | Site string `json:"site"` 13 | PlanterSteamID64 uint64 `json:"planterSteamId"` 14 | PlanterName string `json:"planterName"` 15 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | } 20 | 21 | func newBombPlanted(analyzer *Analyzer, event events.BombPlanted) *BombPlanted { 22 | parser := analyzer.parser 23 | player := event.Player 24 | 25 | return &BombPlanted{ 26 | Frame: parser.CurrentFrame(), 27 | Tick: analyzer.currentTick(), 28 | RoundNumber: analyzer.currentRound.Number, 29 | PlanterName: player.Name, 30 | PlanterSteamID64: player.SteamID64, 31 | IsPlayerControllingBot: player.IsControllingBot(), 32 | Site: converters.BombsiteToString(event.BombEvent.Site), 33 | X: player.LastAlivePosition.X, 34 | Y: player.LastAlivePosition.Y, 35 | Z: player.LastAlivePosition.Z, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/api/challengermode.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | func createChallengermodeAnalyzer(analyzer *Analyzer) { 9 | parser := analyzer.parser 10 | match := analyzer.match 11 | match.gameModeStr = constants.GameModeStrCompetitive 12 | matchStarted := false 13 | 14 | analyzer.matchStarted = func() bool { 15 | return matchStarted 16 | } 17 | 18 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 19 | if !event.OldIsStarted && event.NewIsStarted && !analyzer.isKnifeRound() { 20 | matchStarted = true 21 | analyzer.processMatchStart() 22 | } 23 | }) 24 | 25 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 26 | 27 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 28 | 29 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 30 | 31 | parser.RegisterEventHandler(func(event events.AnnouncementWinPanelMatch) { 32 | analyzer.updatePlayersScores() 33 | matchStarted = false 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/chat_message.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | type ChatMessage struct { 9 | Frame int `json:"frame"` 10 | Tick int `json:"tick"` 11 | RoundNumber int `json:"roundNumber"` 12 | Message string `json:"message"` 13 | SenderSteamID64 uint64 `json:"senderSteamId"` 14 | SenderName string `json:"senderName"` 15 | SenderSide common.Team `json:"senderSide"` 16 | IsSenderAlive bool `json:"isSenderAlive"` 17 | } 18 | 19 | func newChatMessageFromGameEvent(analyzer *Analyzer, event events.ChatMessage) *ChatMessage { 20 | parser := analyzer.parser 21 | return &ChatMessage{ 22 | Frame: parser.CurrentFrame(), 23 | Tick: analyzer.currentTick(), 24 | RoundNumber: analyzer.currentRound.Number, 25 | IsSenderAlive: event.Sender.IsAlive(), 26 | Message: event.Text, 27 | SenderName: event.Sender.Name, 28 | SenderSteamID64: event.Sender.SteamID64, 29 | SenderSide: event.Sender.Team, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/chicken_death.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | ) 6 | 7 | type ChickenDeath struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | KillerSteamID uint64 `json:"killerSteamId"` 12 | WeaponName constants.WeaponName `json:"weaponName"` 13 | } 14 | 15 | func newChickenDeath(frame int, tick int, roundNumber int, killerSteamID uint64, weaponName constants.WeaponName) *ChickenDeath { 16 | chickenDeath := &ChickenDeath{ 17 | Frame: frame, 18 | Tick: tick, 19 | RoundNumber: roundNumber, 20 | WeaponName: weaponName, 21 | KillerSteamID: killerSteamID, 22 | } 23 | 24 | return chickenDeath 25 | } 26 | -------------------------------------------------------------------------------- /pkg/api/chicken_position.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 5 | ) 6 | 7 | type ChickenPosition struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | X float64 `json:"x"` 12 | Y float64 `json:"y"` 13 | Z float64 `json:"z"` 14 | } 15 | 16 | func newChickenPositionFromEntity(analyzer *Analyzer, entity sendtables.Entity) *ChickenPosition { 17 | parser := analyzer.parser 18 | 19 | return &ChickenPosition{ 20 | Frame: parser.CurrentFrame(), 21 | Tick: analyzer.currentTick(), 22 | RoundNumber: analyzer.currentRound.Number, 23 | X: entity.Position().X, 24 | Y: entity.Position().Y, 25 | Z: entity.Position().Z, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/clutch.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type Clutch struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | OpponentCount int `json:"opponentCount"` 12 | Side common.Team `json:"side"` 13 | HasWon bool `json:"hasWon"` 14 | ClutcherSteamID64 uint64 `json:"clutcherSteamId"` 15 | ClutcherName string `json:"clutcherName"` 16 | ClutcherSurvived bool `json:"clutcherSurvived"` 17 | ClutcherKillCount int `json:"clutcherKillCount"` 18 | } 19 | 20 | func newClutch(analyzer *Analyzer, clutcher *common.Player, side common.Team, opponentCount int) *Clutch { 21 | parser := analyzer.parser 22 | 23 | return &Clutch{ 24 | Frame: parser.CurrentFrame(), 25 | Tick: analyzer.currentTick(), 26 | Side: side, 27 | OpponentCount: opponentCount, 28 | ClutcherName: clutcher.Name, 29 | ClutcherSteamID64: clutcher.SteamID64, 30 | RoundNumber: analyzer.currentRound.Number, 31 | ClutcherSurvived: true, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/api/constants/demo_source.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type DemoSource string 4 | 5 | func (source DemoSource) String() string { 6 | return string(source) 7 | } 8 | 9 | const ( 10 | DemoSourceCEVO DemoSource = "cevo" 11 | DemoSourceChallengermode DemoSource = "challengermode" 12 | DemoSourceEbot DemoSource = "ebot" 13 | DemoSourceESEA DemoSource = "esea" 14 | DemoSourceESL DemoSource = "esl" 15 | DemoSourceEsplay DemoSource = "esplay" 16 | DemoSourceEsportal DemoSource = "esportal" 17 | DemoSourceFaceIt DemoSource = "faceit" 18 | DemoSourceFastcup DemoSource = "fastcup" 19 | DemoSourceFiveEPlay DemoSource = "5eplay" 20 | DemoSourceGamersclub DemoSource = "gamersclub" 21 | // "Perfect World" (完美世界) is a Chinese company that Valve partnered with to release CS:GO in China. 22 | DemoSourceMatchZy DemoSource = "matchzy" 23 | DemoSourcePerfectWorld DemoSource = "perfectworld" 24 | DemoSourcePopFlash DemoSource = "popflash" 25 | DemoSourceRenown DemoSource = "renown" 26 | DemoSourceUnknown DemoSource = "unknown" 27 | DemoSourceValve DemoSource = "valve" 28 | ) 29 | 30 | var SupportedDemoSources = []DemoSource{ 31 | DemoSourceChallengermode, 32 | DemoSourceEbot, 33 | DemoSourceESEA, 34 | DemoSourceESL, 35 | DemoSourceEsplay, 36 | DemoSourceEsportal, 37 | DemoSourceFaceIt, 38 | DemoSourceFastcup, 39 | DemoSourceFiveEPlay, 40 | DemoSourcePerfectWorld, 41 | DemoSourcePopFlash, 42 | DemoSourceRenown, 43 | DemoSourceValve, 44 | DemoSourceMatchZy, 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/constants/demo_type.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type DemoType string 4 | 5 | func (demoType DemoType) String() string { 6 | return string(demoType) 7 | } 8 | 9 | const ( 10 | DemoTypeGOTV DemoType = "GOTV" 11 | DemoTypePOV DemoType = "POV" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/api/constants/economy.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type EconomyType string 4 | 5 | func (name EconomyType) String() string { 6 | return string(name) 7 | } 8 | 9 | const ( 10 | EconomyTypePistol EconomyType = "pistol" 11 | EconomyTypeEco EconomyType = "eco" 12 | EconomyTypeSemi EconomyType = "semi" 13 | EconomyTypeForceBuy EconomyType = "force-buy" 14 | EconomyTypeFull EconomyType = "full" 15 | ) 16 | -------------------------------------------------------------------------------- /pkg/api/constants/export_format.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type ExportFormat string 4 | 5 | const ( 6 | ExportFormatCSV ExportFormat = "csv" 7 | ExportFormatJSON ExportFormat = "json" 8 | ExportFormatCSDM ExportFormat = "csdm" // Special CSV export dedicated to the application CS Demo Manager 9 | ) 10 | 11 | var ExportFormats = []ExportFormat{ 12 | ExportFormatCSV, 13 | ExportFormatJSON, 14 | ExportFormatCSDM, 15 | } 16 | -------------------------------------------------------------------------------- /pkg/api/constants/game.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type Game string 4 | 5 | const ( 6 | CSGO Game = "CSGO" 7 | CS2 Game = "CS2" 8 | CS2LT Game = "CS2 LT" 9 | ) 10 | 11 | func (game Game) String() string { 12 | return string(game) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/api/constants/game_type.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/converters" 5 | ) 6 | 7 | type GameType int 8 | 9 | func (gameType GameType) String() string { 10 | return converters.IntToString(int(gameType)) 11 | } 12 | 13 | const ( 14 | GameTypeClassic GameType = 0 15 | GameTypeGunGame GameType = 1 16 | GameTypeTraining GameType = 2 17 | GameTypeCustom GameType = 3 18 | GameTypeCoOperative GameType = 4 19 | GameTypeSkirmish GameType = 5 20 | GameTypeFFA GameType = 6 21 | ) 22 | 23 | type GameMode int 24 | 25 | func (gameMode GameMode) String() string { 26 | return converters.IntToString(int(gameMode)) 27 | } 28 | 29 | // When GameType is Classic 30 | const ( 31 | GameModeCasual GameMode = 0 32 | GameModeCompetitive GameMode = 1 33 | GameModeScrimmage2V2 GameMode = 2 34 | GameModeScrimmage5V5 GameMode = 3 35 | ) 36 | 37 | // When GameType is GunGame 38 | const ( 39 | GameModeProgressive GameMode = 0 40 | GameModeBomb GameMode = 1 41 | GameModeDeathmatch GameMode = 2 42 | ) 43 | 44 | // When GameType is Training 45 | const ( 46 | GameModeTraining GameMode = 0 47 | ) 48 | 49 | // When GameType is Custom 50 | const ( 51 | GameModeCustom GameMode = 0 52 | ) 53 | 54 | // When GameType is CoOperative 55 | const ( 56 | GameModeCoOperative GameMode = 0 57 | GameModeCoOperativeMission GameMode = 1 58 | ) 59 | 60 | // When GameType is Skirmish 61 | const ( 62 | GameModeSkirmish GameMode = 0 63 | ) 64 | 65 | // When GameType is FFA 66 | const ( 67 | GameModeSurvival GameMode = 0 68 | ) 69 | 70 | // Game mode as a string reported in CSVCMsg_ServerInfo messages. 71 | type GameModeStr string 72 | 73 | func (gameMode GameModeStr) String() string { 74 | return string(gameMode) 75 | } 76 | 77 | const ( 78 | GameModeStrCasual GameModeStr = "casual" 79 | GameModeStrPremier GameModeStr = "premier" 80 | GameModeStrCompetitive GameModeStr = "competitive" 81 | GameModeStrScrimmage2V2 GameModeStr = "scrimcomp2v2" 82 | GameModeStrScrimmage5v5 GameModeStr = "scrimcomp5v5" 83 | GameModeStrDeathmatch GameModeStr = "deathmatch" 84 | GameModeStrGunGameProgressive GameModeStr = "gungameprogressive" 85 | GameModeStrGunGameBomb GameModeStr = "gungametrbomb" 86 | GameModeStrCustom GameModeStr = "custom" 87 | GameModeStrCoOperative GameModeStr = "cooperative" 88 | GameModeStrCoOperativeMission GameModeStr = "coopmission" 89 | GameModeStrSkirmish GameModeStr = "skirmish" 90 | GameModeStrSurvival GameModeStr = "survival" 91 | ) 92 | 93 | var GameModeMapping = map[GameType]map[GameMode]GameModeStr{ 94 | GameTypeClassic: { 95 | GameModeCasual: GameModeStrCasual, 96 | GameModeCompetitive: GameModeStrCompetitive, 97 | GameModeScrimmage2V2: GameModeStrScrimmage2V2, 98 | GameModeScrimmage5V5: GameModeStrScrimmage5v5, 99 | }, 100 | GameTypeGunGame: { 101 | GameModeProgressive: GameModeStrGunGameProgressive, 102 | GameModeBomb: GameModeStrGunGameBomb, 103 | GameModeDeathmatch: GameModeStrDeathmatch, 104 | }, 105 | GameTypeCustom: { 106 | GameModeCustom: GameModeStrCustom, 107 | }, 108 | GameTypeCoOperative: { 109 | GameModeCoOperative: GameModeStrCoOperative, 110 | GameModeCoOperativeMission: GameModeStrCoOperativeMission, 111 | }, 112 | GameTypeSkirmish: { 113 | GameModeSkirmish: GameModeStrSkirmish, 114 | }, 115 | GameTypeFFA: { 116 | GameModeSurvival: GameModeStrSurvival, 117 | }, 118 | } 119 | -------------------------------------------------------------------------------- /pkg/api/constants/round_win_status.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 4 | 5 | type RoundWinStatus byte 6 | 7 | const ( 8 | RoundWinStatusUnassigned RoundWinStatus = 0 9 | RoundWinStatusDraw RoundWinStatus = 1 10 | RoundWinStatusTWon RoundWinStatus = RoundWinStatus(common.TeamTerrorists) 11 | RoundWinStatusCTWon RoundWinStatus = RoundWinStatus(common.TeamCounterTerrorists) 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/api/constants/team_letter.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type TeamLetter string 4 | 5 | func (name TeamLetter) String() string { 6 | return string(name) 7 | } 8 | 9 | const ( 10 | TeamLetterA TeamLetter = "A" 11 | TeamLetterB TeamLetter = "B" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/api/constants/weapon.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type WeaponType string 4 | 5 | func (name WeaponType) String() string { 6 | return string(name) 7 | } 8 | 9 | const ( 10 | WeaponTypeUnknown WeaponType = "unknown" 11 | WeaponTypePistol WeaponType = "pistol" 12 | WeaponTypeSMG WeaponType = "smg" 13 | WeaponTypeShotgun WeaponType = "shotgun" 14 | WeaponTypeRifle WeaponType = "rifle" 15 | WeaponTypeSniper WeaponType = "sniper" 16 | WeaponTypeMachineGun WeaponType = "machine_gun" 17 | WeaponTypeGrenade WeaponType = "grenade" 18 | WeaponTypeEquipment WeaponType = "equipment" 19 | WeaponTypeMelee WeaponType = "melee" 20 | WeaponTypeWorld WeaponType = "world" 21 | ) 22 | 23 | type WeaponName string 24 | 25 | func (name WeaponName) String() string { 26 | return string(name) 27 | } 28 | 29 | const ( 30 | WeaponAK47 WeaponName = "AK-47" 31 | WeaponAUG WeaponName = "AUG" 32 | WeaponAWP WeaponName = "AWP" 33 | WeaponBomb WeaponName = "C4" 34 | WeaponCZ75 WeaponName = "CZ75 Auto" 35 | WeaponDecoy WeaponName = "Decoy Grenade" 36 | WeaponDeagle WeaponName = "Desert Eagle" 37 | WeaponDefuseKit WeaponName = "Defuse Kit" 38 | WeaponDualBerettas WeaponName = "Dual Berettas" 39 | WeaponFamas WeaponName = "FAMAS" 40 | WeaponFiveSeven WeaponName = "Five-SeveN" 41 | WeaponFlashbang WeaponName = "Flashbang" 42 | WeaponG3SG1 WeaponName = "G3SG1" 43 | WeaponGalilAR WeaponName = "Galil AR" 44 | WeaponGlock WeaponName = "Glock-18" 45 | WeaponHEGrenade WeaponName = "HE Grenade" 46 | WeaponHelmet WeaponName = "Kevlar + Helmet" 47 | WeaponKevlar WeaponName = "Kevlar Vest" 48 | WeaponIncendiary WeaponName = "Incendiary Grenade" 49 | WeaponKnife WeaponName = "Knife" 50 | WeaponM249 WeaponName = "M249" 51 | WeaponM4A1 WeaponName = "M4A1" 52 | WeaponM4A4 WeaponName = "M4A4" 53 | WeaponMac10 WeaponName = "MAC-10" 54 | WeaponMAG7 WeaponName = "MAG-7" 55 | WeaponMolotov WeaponName = "Molotov" 56 | WeaponMP5 WeaponName = "MP5-SD" 57 | WeaponMP7 WeaponName = "MP7" 58 | WeaponMP9 WeaponName = "MP9" 59 | WeaponNegev WeaponName = "Negev" 60 | WeaponNova WeaponName = "Nova" 61 | WeaponP2000 WeaponName = "P2000" 62 | WeaponP250 WeaponName = "P250" 63 | WeaponP90 WeaponName = "P90" 64 | WeaponPPBizon WeaponName = "PP-Bizon" 65 | WeaponRevolver WeaponName = "R8 Revolver" 66 | WeaponSawedOff WeaponName = "Sawed-Off" 67 | WeaponScar20 WeaponName = "SCAR-20" 68 | WeaponScout WeaponName = "SSG 08" 69 | WeaponSG553 WeaponName = "SG 553" 70 | WeaponSmoke WeaponName = "Smoke Grenade" 71 | WeaponTec9 WeaponName = "Tec-9" 72 | WeaponUMP45 WeaponName = "UMP-45" 73 | WeaponUnknown WeaponName = "Unknown" 74 | WeaponUSP WeaponName = "USP-S" 75 | WeaponWorld WeaponName = "World" 76 | WeaponXM1014 WeaponName = "XM1014" 77 | WeaponZeus WeaponName = "Zeus x27" 78 | ) 79 | -------------------------------------------------------------------------------- /pkg/api/damage.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/internal/math" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 9 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 10 | ) 11 | 12 | type Damage struct { 13 | Frame int `json:"frame"` 14 | Tick int `json:"tick"` 15 | RoundNumber int `json:"roundNumber"` 16 | HealthDamage int `json:"healthDamage"` 17 | ArmorDamage int `json:"armorDamage"` 18 | AttackerSteamID64 uint64 `json:"attackerSteamId"` 19 | AttackerSide common.Team `json:"attackerSide"` 20 | AttackerTeamName string `json:"attackerTeamName"` 21 | IsAttackerControllingBot bool `json:"isAttackerControllingBot"` 22 | VictimHealth int `json:"victimHealth"` 23 | VictimNewHealth int `json:"victimNewHealth"` 24 | VictimArmor int `json:"victimArmor"` 25 | VictimNewArmor int `json:"victimNewArmor"` 26 | VictimSteamID64 uint64 `json:"victimSteamId"` 27 | VictimSide common.Team `json:"victimSide"` 28 | VictimTeamName string `json:"victimTeamName"` 29 | IsVictimControllingBot bool `json:"isVictimControllingBot"` 30 | HitGroup events.HitGroup `json:"hitgroup"` 31 | WeaponName constants.WeaponName `json:"weaponName"` 32 | WeaponType constants.WeaponType `json:"weaponType"` 33 | WeaponUniqueID string `json:"weaponUniqueId"` 34 | } 35 | 36 | func (damage *Damage) IsGrenadeWeapon() bool { 37 | return damage.WeaponType == constants.WeaponTypeGrenade 38 | } 39 | 40 | func (damage *Damage) isValidPlayerDamageEvent(player *Player) bool { 41 | if damage.AttackerSteamID64 != player.SteamID64 { 42 | return false 43 | } 44 | if damage.AttackerSteamID64 == damage.VictimSteamID64 { 45 | return false 46 | } 47 | if damage.VictimSteamID64 == 0 { 48 | return false 49 | } 50 | if damage.IsAttackerControllingBot { 51 | return false 52 | } 53 | if damage.AttackerSide == damage.VictimSide { 54 | return false 55 | } 56 | 57 | return true 58 | } 59 | 60 | func newDamageFromGameEvent(analyzer *Analyzer, event events.PlayerHurt) *Damage { 61 | if event.Weapon == nil { 62 | fmt.Println("Player hurt event without weapon occurred") 63 | return nil 64 | } 65 | parser := analyzer.parser 66 | match := analyzer.match 67 | attackerSteamID := uint64(0) 68 | attackerSide := common.TeamUnassigned 69 | attackerTeamName := "World" 70 | isAttackerControllingBot := false 71 | if event.Attacker != nil { 72 | attackerSteamID = event.Attacker.SteamID64 73 | attackerSide = event.Attacker.Team 74 | attackerTeamName = match.Team(event.Attacker.Team).Name 75 | isAttackerControllingBot = event.Attacker.IsControllingBot() 76 | } 77 | 78 | return &Damage{ 79 | RoundNumber: analyzer.currentRound.Number, 80 | Frame: parser.CurrentFrame(), 81 | Tick: analyzer.currentTick(), 82 | HealthDamage: math.Max(0, event.HealthDamageTaken), 83 | ArmorDamage: math.Max(0, event.ArmorDamageTaken), 84 | VictimHealth: math.Max(0, event.Player.Health()), 85 | VictimArmor: math.Max(0, event.Player.Armor()), 86 | VictimNewHealth: math.Max(0, event.Health), 87 | VictimNewArmor: math.Max(0, event.Armor), 88 | IsVictimControllingBot: event.Player.IsControllingBot(), 89 | AttackerSteamID64: attackerSteamID, 90 | AttackerSide: attackerSide, 91 | AttackerTeamName: attackerTeamName, 92 | IsAttackerControllingBot: isAttackerControllingBot, 93 | VictimSteamID64: event.Player.SteamID64, 94 | VictimSide: event.Player.Team, 95 | VictimTeamName: match.Team(event.Player.Team).Name, 96 | WeaponName: equipmentToWeaponName[event.Weapon.Type], 97 | WeaponType: getEquipmentWeaponType(*event.Weapon), 98 | HitGroup: event.HitGroup, 99 | WeaponUniqueID: event.Weapon.UniqueID2().String(), 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/api/decoy_start.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 8 | ) 9 | 10 | type DecoyStart struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 20 | ThrowerName string `json:"throwerName"` 21 | ThrowerSide common.Team `json:"throwerSide"` 22 | ThrowerTeamName string `json:"throwerTeamName"` 23 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 24 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 25 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 26 | ThrowerPitch float32 `json:"throwerPitch"` 27 | ThrowerYaw float32 `json:"throwerYaw"` 28 | } 29 | 30 | func newDecoyStartFromGameEvent(analyzer *Analyzer, event events.DecoyStart) *DecoyStart { 31 | grenade := event.Grenade 32 | if grenade == nil { 33 | fmt.Println("Grenade nil in decoy start event") 34 | return nil 35 | } 36 | 37 | thrower := event.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in decoy start event") 40 | return nil 41 | } 42 | 43 | throwerTeam := thrower.Team 44 | parser := analyzer.parser 45 | var projectileID int64 46 | for _, projectile := range parser.GameState().GrenadeProjectiles() { 47 | if projectile.WeaponInstance.UniqueID2() == grenade.UniqueID2() { 48 | projectileID = projectile.UniqueID() 49 | } 50 | } 51 | 52 | velocity := thrower.Velocity() 53 | 54 | return &DecoyStart{ 55 | Frame: parser.CurrentFrame(), 56 | Tick: analyzer.currentTick(), 57 | RoundNumber: analyzer.currentRound.Number, 58 | GrenadeID: grenade.UniqueID2().String(), 59 | ProjectileID: projectileID, 60 | X: event.Position.X, 61 | Y: event.Position.Y, 62 | Z: event.Position.Z, 63 | ThrowerSteamID64: thrower.SteamID64, 64 | ThrowerName: thrower.Name, 65 | ThrowerSide: throwerTeam, 66 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 67 | ThrowerVelocityX: velocity.X, 68 | ThrowerVelocityY: velocity.Y, 69 | ThrowerVelocityZ: velocity.Z, 70 | ThrowerYaw: thrower.ViewDirectionX(), 71 | ThrowerPitch: thrower.ViewDirectionY(), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/api/demo_source.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/akiver/cs-demo-analyzer/internal/slice" 8 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 9 | ) 10 | 11 | func FormatValidDemoSources() string { 12 | var sources []string 13 | for _, source := range constants.SupportedDemoSources { 14 | sources = append(sources, string(source)) 15 | } 16 | 17 | return "[" + strings.Join(sources, ",") + "]" 18 | } 19 | 20 | func ValidateDemoSource(source constants.DemoSource) error { 21 | isValid := slice.Contains(constants.SupportedDemoSources, source) 22 | if isValid { 23 | return nil 24 | } 25 | 26 | return fmt.Errorf("invalid source provided, valid sources: %s", FormatValidDemoSources()) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/ebot.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | s "github.com/akiver/cs-demo-analyzer/internal/strings" 8 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 9 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 10 | sendtables "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 11 | ) 12 | 13 | func createEbotAnalyzer(analyzer *Analyzer) { 14 | match := analyzer.match 15 | match.gameModeStr = constants.GameModeStrCompetitive 16 | parser := analyzer.parser 17 | matchStarted := true 18 | matchStartDetected := false 19 | ctWantStop := false 20 | tWantStop := false 21 | lastMatchRoundOfficiallyEndTick := -1 // Used to detect stop command requests 22 | lastRoundEndTick := -1 // Used to detect backup restore 23 | playersTeamChangeTick := -1 // Used to detect swap team after a possible knife round 24 | 25 | analyzer.matchStarted = func() bool { 26 | return matchStarted 27 | } 28 | 29 | resetCurrentRound := func() { 30 | currentRound := analyzer.currentRound 31 | currentRound.StartFrame = analyzer.parser.CurrentFrame() 32 | currentRound.StartTick = analyzer.currentTick() 33 | currentRound.FreezeTimeEndFrame = -1 34 | currentRound.FreezeTimeEndTick = -1 35 | } 36 | 37 | parser.RegisterEventHandler(func(event events.SayText) { 38 | if analyzer.currentRound.Number > 1 { 39 | return 40 | } 41 | 42 | text := s.RemoveInvisibleChars(event.Text) 43 | if !tWantStop { 44 | tWantStop = strings.Contains(text, "(T) want to stop") 45 | } 46 | if !ctWantStop { 47 | ctWantStop = strings.Contains(text, "(CT) want to stop") 48 | } 49 | 50 | isMatchStopped := ctWantStop && tWantStop 51 | if isMatchStopped { 52 | ctWantStop = false 53 | tWantStop = false 54 | matchStarted = false 55 | matchStartDetected = false 56 | resetCurrentRound() 57 | analyzer.reset() 58 | } 59 | }) 60 | 61 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 62 | parser.ServerClasses().FindByName("CCSTeam").OnEntityCreated(func(entity sendtables.Entity) { 63 | // Detects possible teams switch after the end of a knife round (!switch command). 64 | // If the teams are not switched (!stay command) the following players prop of the team entity doesn't change. 65 | // Teams data such as its name are updated just before this prop change. 66 | var playersProps []sendtables.Property 67 | if analyzer.isSource2 { 68 | // The array is split into 4 props in Source 2 69 | for i := 0; i < 4; i++ { 70 | iStr := fmt.Sprintf("%04d", i) 71 | playersProps = append(playersProps, entity.Property("m_aPlayers."+iStr)) 72 | } 73 | } else { 74 | playersProps = append(playersProps, entity.Property("\"player_array\"")) 75 | } 76 | 77 | for _, prop := range playersProps { 78 | prop.OnUpdate(func(value sendtables.PropertyValue) { 79 | if value.Any == nil || analyzer.currentTick() <= 1 || analyzer.currentRound.Number > 1 { 80 | return 81 | } 82 | 83 | if playersTeamChangeTick == analyzer.currentTick() { 84 | analyzer.reset() 85 | } 86 | playersTeamChangeTick = analyzer.currentTick() 87 | }) 88 | } 89 | }) 90 | 91 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 92 | analyzer.registerUnknownPlayers() 93 | matchStarted = event.NewIsStarted 94 | if matchStarted && !matchStartDetected { 95 | currentRound := analyzer.currentRound 96 | currentRound.StartFrame = parser.CurrentFrame() 97 | currentRound.StartTick = analyzer.currentTick() 98 | analyzer.updateTeamNames() 99 | analyzer.createPlayersEconomies() 100 | matchStartDetected = true 101 | } else if len(match.Rounds) == 0 { 102 | matchStartDetected = false 103 | } 104 | 105 | // When a stop command is requested by a player after the first round there is a round officially end event between game restart and match start: 106 | // Player says !stop -> m_bGameRestart -> m_bHasMatchStarted -> round_end_officially -> round_start -> m_bHasMatchStarted 107 | // When it's a restart for a real match live, there is no round official end event: 108 | // Going live -> m_bGameRestart -> m_bHasMatchStarted -> round_start -> m_bHasMatchStarted 109 | // Stop commands during the first round don't trigger any game restart. We rely on chat messages for this case. 110 | 111 | if matchStarted && analyzer.currentRound.Number > 1 { 112 | isMatchStopped := !analyzer.secondsHasPassedSinceTick(5, lastMatchRoundOfficiallyEndTick) 113 | if isMatchStopped { 114 | matchStarted = false 115 | } 116 | } 117 | }) 118 | 119 | parser.RegisterEventHandler(func(event events.RoundFreezetimeChanged) { 120 | if !analyzer.matchStarted() { 121 | return 122 | } 123 | // It may not be accurate to create players economy on round start because it's possible to buy 124 | // a few ticks before the round start event and so may results in incorrect values. 125 | // Do it when the freeze time starts, it's updated just before round start events. 126 | if event.NewIsFreezetime { 127 | analyzer.createPlayersEconomies() 128 | } else { 129 | analyzer.currentRound.FreezeTimeEndTick = analyzer.currentTick() 130 | analyzer.currentRound.FreezeTimeEndFrame = parser.CurrentFrame() 131 | analyzer.lastFreezeTimeEndTick = analyzer.currentTick() 132 | } 133 | }) 134 | }) 135 | 136 | parser.RegisterEventHandler(func(event events.RoundStart) { 137 | isBackupRestoration := analyzer.currentTick() == lastRoundEndTick 138 | if isBackupRestoration { 139 | matchStarted = true 140 | resetCurrentRound() 141 | return 142 | } 143 | 144 | if !analyzer.matchStarted() || analyzer.currentTick() == 0 || len(match.Rounds) == 0 { 145 | return 146 | } 147 | 148 | analyzer.createRound() 149 | }) 150 | 151 | parser.RegisterEventHandler(func(event events.RoundEnd) { 152 | lastRoundEndTick = analyzer.currentTick() 153 | 154 | knifeKillCount := 0 155 | killCount := 0 156 | for _, kill := range analyzer.match.Kills { 157 | if kill.RoundNumber != analyzer.currentRound.Number { 158 | continue 159 | } 160 | if kill.WeaponName == constants.WeaponKnife { 161 | knifeKillCount++ 162 | } 163 | killCount++ 164 | } 165 | 166 | isKnifeRound := killCount > 0 && killCount == knifeKillCount 167 | if isKnifeRound { 168 | analyzer.reset() 169 | matchStarted = false 170 | matchStartDetected = false 171 | } 172 | }) 173 | 174 | parser.RegisterEventHandler(func(event events.RoundEndOfficial) { 175 | lastMatchRoundOfficiallyEndTick = analyzer.currentTick() 176 | if !analyzer.matchStarted() { 177 | return 178 | } 179 | 180 | isBackupRestoration := analyzer.currentTick() == lastRoundEndTick 181 | if isBackupRestoration { 182 | return 183 | } 184 | 185 | match.Rounds = append(match.Rounds, analyzer.currentRound) 186 | }) 187 | 188 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 189 | } 190 | -------------------------------------------------------------------------------- /pkg/api/economy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | ) 7 | 8 | func getValidPlayerCount(players []*common.Player) int { 9 | playerCount := 0 10 | for _, player := range players { 11 | if !player.IsBot && !player.IsUnknown { 12 | playerCount++ 13 | } 14 | } 15 | 16 | return playerCount 17 | } 18 | 19 | func computePlayerEconomyType(analyzer *Analyzer, player *common.Player) constants.EconomyType { 20 | if analyzer.isFirstRoundOfHalf && analyzer.match.OvertimeCount == 0 { 21 | return constants.EconomyTypePistol 22 | } 23 | 24 | equipmentValue := player.EquipmentValueCurrent() 25 | if equipmentValue <= 1000 { 26 | return constants.EconomyTypeEco 27 | } 28 | 29 | playerSide := player.Team 30 | minFullEquipmentValue := 4500 31 | if playerSide == common.TeamTerrorists { 32 | minFullEquipmentValue = 4000 33 | } 34 | 35 | if equipmentValue >= minFullEquipmentValue { 36 | return constants.EconomyTypeFull 37 | } 38 | 39 | if len(analyzer.match.Rounds) > 0 { 40 | previousRound := analyzer.match.Rounds[len(analyzer.match.Rounds)-1] 41 | if previousRound.WinnerSide != playerSide && player.Money() <= 400 { 42 | return constants.EconomyTypeForceBuy 43 | } 44 | } 45 | 46 | return constants.EconomyTypeSemi 47 | } 48 | 49 | func computeTeamEconomyType(analyzer *Analyzer, team *common.TeamState) constants.EconomyType { 50 | if analyzer.isFirstRoundOfHalf && analyzer.match.OvertimeCount == 0 { 51 | return constants.EconomyTypePistol 52 | } 53 | 54 | teamSide := team.Team() 55 | playerCount := getValidPlayerCount(team.Members()) 56 | equipmentValue := team.CurrentEquipmentValue() 57 | if equipmentValue <= 1000*playerCount { 58 | return constants.EconomyTypeEco 59 | } 60 | 61 | minFullEquipmentValue := 4500 * playerCount 62 | if teamSide == common.TeamTerrorists { 63 | minFullEquipmentValue = 4000 * playerCount 64 | } 65 | 66 | if equipmentValue >= minFullEquipmentValue { 67 | return constants.EconomyTypeFull 68 | } 69 | 70 | if len(analyzer.match.Rounds) > 0 { 71 | previousRound := analyzer.match.Rounds[len(analyzer.match.Rounds)-1] 72 | money := 0 73 | for _, player := range team.Members() { 74 | money += player.Money() 75 | } 76 | 77 | if previousRound.WinnerSide != teamSide && money < 400*playerCount { 78 | return constants.EconomyTypeForceBuy 79 | } 80 | } 81 | 82 | return constants.EconomyTypeSemi 83 | } 84 | -------------------------------------------------------------------------------- /pkg/api/esea.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 9 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 10 | ) 11 | 12 | const ( 13 | maxPlayers = 64 14 | counterTerroristsTeamNumber = byte(common.TeamCounterTerrorists) 15 | terroristsTeamNumber = byte(common.TeamTerrorists) 16 | ) 17 | 18 | func createEseaAnalyzer(analyzer *Analyzer) { 19 | parser := analyzer.parser 20 | match := analyzer.match 21 | match.gameModeStr = constants.GameModeStrCompetitive 22 | matchStarted := false 23 | playerSwapTickDetected := -1 // Keep track of the last tick detected that contains a player Team swap, used to detect teams side switch. 24 | playerSwappedAtTickCount := 0 // How many players have been swapped at the same tick, used to detect teams side switch. 25 | consecutiveMatchRestartCount := 0 // ESEA uses the old school "LO3" 26 | lastMatchStartTick := 0 // Keep track of the last match restart tick to detect LO3 27 | shouldSwapTeams := false 28 | analyzer.postProcess = func(analyzer *Analyzer) { 29 | // noop 30 | } 31 | 32 | analyzer.matchStarted = func() bool { 33 | return matchStarted 34 | } 35 | 36 | // Players scores are reset to 0 at the end of the match before the last round really ended. 37 | // Listen and update PlayersBySteamID scores to have correct values at the end of the match. 38 | onScoreUpdate := func(value sendtables.PropertyValue) { 39 | if !matchStarted { 40 | return 41 | } 42 | 43 | analyzer.updatePlayersScores() 44 | } 45 | 46 | parser.RegisterEventHandler(func(event events.MatchStart) { 47 | analyzer.registerUnknownPlayers() 48 | currentTick := analyzer.currentTick() 49 | 50 | if analyzer.secondsHasPassedSinceTick(5, lastMatchStartTick) { 51 | lastMatchStartTick = currentTick 52 | consecutiveMatchRestartCount = 1 53 | matchStarted = false 54 | return 55 | } 56 | 57 | consecutiveMatchRestartCount += 1 58 | if consecutiveMatchRestartCount == 3 { 59 | matchStarted = true 60 | consecutiveMatchRestartCount = 0 61 | currentRound := analyzer.currentRound 62 | currentRound.StartTick = analyzer.currentTick() 63 | currentRound.StartFrame = parser.CurrentFrame() 64 | 65 | // Some players may have joined the wrong team when entering the server, in that case they are silently 66 | // switched to the correct team right before the LO3. 67 | // Update the team of those players only for the first LO3. 68 | if currentRound.Number == 1 { 69 | analyzer.updatePlayersCurrentTeam() 70 | analyzer.updateTeamNames() 71 | } 72 | 73 | if shouldSwapTeams { 74 | match.swapTeams() 75 | analyzer.currentRound.TeamASide = *match.TeamA.CurrentSide 76 | analyzer.currentRound.TeamBSide = *match.TeamB.CurrentSide 77 | shouldSwapTeams = false 78 | } 79 | } 80 | 81 | lastMatchStartTick = currentTick 82 | }) 83 | 84 | parser.RegisterEventHandler(func(event events.RoundStart) { 85 | if !analyzer.matchStarted() { 86 | return 87 | } 88 | 89 | match.Rounds = append(match.Rounds, analyzer.currentRound) 90 | analyzer.createRound() 91 | }) 92 | 93 | parser.RegisterEventHandler(func(event events.RoundFreezetimeChanged) { 94 | freezetimeEnded := !event.NewIsFreezetime 95 | if freezetimeEnded { 96 | analyzer.currentRound.FreezeTimeEndTick = analyzer.currentTick() 97 | analyzer.currentRound.FreezeTimeEndFrame = parser.CurrentFrame() 98 | analyzer.lastFreezeTimeEndTick = analyzer.currentTick() 99 | } 100 | }) 101 | 102 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 103 | parser.ServerClasses().FindByName("CCSGameRulesProxy").OnEntityCreated(func(entity sendtables.Entity) { 104 | gameRestartProp := entity.Property("cs_gamerules_data.m_bGameRestart") 105 | if gameRestartProp == nil { 106 | gameRestartProp = entity.Property("m_pGameRules.m_bGameRestart") 107 | } 108 | gameRestartProp.OnUpdate(func(value sendtables.PropertyValue) { 109 | isRestarting := value.BoolVal() 110 | if isRestarting && matchStarted { 111 | matchStarted = false 112 | if len(match.Rounds) > 1 { 113 | match.Rounds = append(match.Rounds, analyzer.currentRound) 114 | analyzer.createRound() 115 | } 116 | } 117 | 118 | analyzer.isFirstRoundOfHalf = true 119 | }) 120 | }) 121 | 122 | var playerClass sendtables.ServerClass 123 | if analyzer.isSource2 { 124 | playerClass = parser.ServerClasses().FindByName("CCSPlayerController") 125 | } else { 126 | playerClass = parser.ServerClasses().FindByName("CCSPlayer") 127 | } 128 | playerClass.OnEntityCreated(func(entity sendtables.Entity) { 129 | var moneyStartProp sendtables.Property 130 | if analyzer.isSource2 { 131 | moneyStartProp = entity.Property("m_pInGameMoneyServices.m_iStartAccount") 132 | } else { 133 | moneyStartProp = entity.Property("m_iStartAccount") 134 | } 135 | moneyStartProp.OnUpdate(func(value sendtables.PropertyValue) { 136 | analyzer.createPlayersEconomies() 137 | }) 138 | 139 | entity.Property("m_iCoachingTeam").OnUpdate(func(value sendtables.PropertyValue) { 140 | teamNumber := common.Team(value.Int()) 141 | if teamNumber != common.TeamCounterTerrorists && teamNumber != common.TeamTerrorists { 142 | return 143 | } 144 | 145 | // Remove coaches from players 146 | for _, player := range parser.GameState().Participants().All() { 147 | if player.EntityID == entity.ID() { 148 | if analyzer.match.PlayersBySteamID[player.SteamID64] != nil { 149 | delete(analyzer.match.PlayersBySteamID, player.SteamID64) 150 | } 151 | break 152 | } 153 | } 154 | }) 155 | 156 | // Teams swap detection, it occurs when all players are switched to the opposite Team at the same tick. 157 | // The team swapping is delayed at the next LO3 because some events related to team sides may happen between 158 | // the swap detection and the end of the current round. 159 | entity.Property("m_iTeamNum").OnUpdate(func(value sendtables.PropertyValue) { 160 | if len(match.Rounds) < 1 { 161 | return 162 | } 163 | 164 | if analyzer.currentTick() == playerSwapTickDetected { 165 | teamNumber := byte(value.Int()) 166 | if teamNumber == counterTerroristsTeamNumber || teamNumber == terroristsTeamNumber { 167 | playerSwappedAtTickCount++ 168 | validPlayerCount := len(parser.GameState().Participants().Playing()) 169 | if playerSwappedAtTickCount == validPlayerCount { 170 | playerSwapTickDetected = 0 171 | shouldSwapTeams = true 172 | } 173 | } 174 | } else { 175 | playerSwappedAtTickCount = 1 176 | } 177 | 178 | playerSwapTickDetected = analyzer.currentTick() 179 | }) 180 | 181 | if analyzer.isSource2 { 182 | entity.Property("m_iScore").OnUpdate(onScoreUpdate) 183 | } 184 | }) 185 | 186 | if !analyzer.isSource2 { 187 | parser.ServerClasses().FindByName("CCSPlayerResource").OnEntityCreated(func(entity sendtables.Entity) { 188 | for i := 0; i < maxPlayers; i++ { 189 | iAsString := fmt.Sprintf("%03d", i) 190 | scoreProp := entity.Property("m_iScore." + iAsString) 191 | if scoreProp != nil { 192 | scoreProp.OnUpdate(onScoreUpdate) 193 | } 194 | } 195 | }) 196 | } 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /pkg/api/esplay.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 5 | ) 6 | 7 | func createEsplayAnalyzer(analyzer *Analyzer) { 8 | parser := analyzer.parser 9 | matchStarted := false 10 | 11 | analyzer.matchStarted = func() bool { 12 | return matchStarted 13 | } 14 | 15 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 16 | matchStarted = event.NewIsStarted 17 | if matchStarted { 18 | analyzer.processMatchStart() 19 | } 20 | }) 21 | 22 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 23 | 24 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 25 | 26 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 27 | 28 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/api/esportal.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | func createEsportalAnalyzer(analyzer *Analyzer) { 9 | match := analyzer.match 10 | match.gameModeStr = constants.GameModeStrCompetitive 11 | parser := analyzer.parser 12 | isMatchStarted := false 13 | 14 | analyzer.matchStarted = func() bool { 15 | return isMatchStarted 16 | } 17 | 18 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 19 | if !event.OldIsStarted && event.NewIsStarted { 20 | isMatchStarted = true 21 | analyzer.processMatchStart() 22 | } 23 | }) 24 | 25 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 26 | 27 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 28 | 29 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 30 | 31 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/api/export_format.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/akiver/cs-demo-analyzer/internal/slice" 8 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 9 | ) 10 | 11 | func FormatValidExportFormats() string { 12 | var formats []string 13 | for _, format := range constants.ExportFormats { 14 | formats = append(formats, string(format)) 15 | } 16 | 17 | return "[" + strings.Join(formats, ",") + "]" 18 | } 19 | 20 | func ValidateExportFormat(format constants.ExportFormat) error { 21 | isValid := slice.Contains(constants.ExportFormats, constants.ExportFormat(format)) 22 | if isValid { 23 | return nil 24 | } 25 | 26 | return fmt.Errorf("invalid format provided, valid formats: %s", FormatValidExportFormats()) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/export_json.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | ) 8 | 9 | func buildOutputFilePath(match *Match, outputPath string) (string, error) { 10 | if outputPath == "" { 11 | return match.DemoFilePath + ".json", nil 12 | } 13 | 14 | stat, err := os.Stat(outputPath) 15 | if err != nil { 16 | return "", errors.New("invalid output provided, make sure the path exists and you have write access") 17 | } 18 | 19 | if stat.IsDir() { 20 | return outputPath + string(os.PathSeparator) + match.DemoFileName + ".json", nil 21 | } 22 | 23 | return outputPath, nil 24 | } 25 | 26 | func exportMatchToJSON(match *Match, outputPath string, minify bool) error { 27 | var err error 28 | outputFilePath, err := buildOutputFilePath(match, outputPath) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | var jsonString []byte 34 | if minify { 35 | jsonString, err = json.Marshal(match) 36 | } else { 37 | jsonString, err = json.MarshalIndent(match, "", " ") 38 | } 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | err = os.WriteFile(outputFilePath, jsonString, os.ModePerm) 45 | 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /pkg/api/faceit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | func createFaceItAnalyzer(analyzer *Analyzer) { 9 | parser := analyzer.parser 10 | match := analyzer.match 11 | match.gameModeStr = constants.GameModeStrCompetitive 12 | matchStarted := false 13 | 14 | analyzer.matchStarted = func() bool { 15 | return matchStarted 16 | } 17 | 18 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 19 | if !event.OldIsStarted && event.NewIsStarted && !analyzer.isKnifeRound() { 20 | matchStarted = true 21 | analyzer.processMatchStart() 22 | } 23 | }) 24 | 25 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 26 | 27 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 28 | 29 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 30 | 31 | parser.RegisterEventHandler(func(event events.AnnouncementWinPanelMatch) { 32 | analyzer.updatePlayersScores() 33 | matchStarted = false 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/fastcup.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 5 | st "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 6 | ) 7 | 8 | func createFastcupAnalyzer(analyzer *Analyzer) { 9 | parser := analyzer.parser 10 | matchStarted := false 11 | 12 | analyzer.matchStarted = func() bool { 13 | return matchStarted 14 | } 15 | 16 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 17 | parser.ServerClasses().FindByName("CCSGameRulesProxy").OnEntityCreated(func(entity st.Entity) { 18 | // Match start detection - the match really starts when: 19 | // 1. The warmup period is over 20 | // 2. Players can buy, otherwise it means it's the knife round 21 | var cantBuyProp st.Property 22 | if analyzer.isSource2 { 23 | cantBuyProp = entity.Property("m_pGameRules.m_bTCantBuy") 24 | } else { 25 | cantBuyProp = entity.Property("cs_gamerules_data.m_bTCantBuy") 26 | } 27 | 28 | cantBuyProp.OnUpdate(func(val st.PropertyValue) { 29 | if parser.GameState().IsWarmupPeriod() { 30 | return 31 | } 32 | 33 | playersCanBuy := !val.BoolVal() 34 | if playersCanBuy && !analyzer.matchStarted() { 35 | matchStarted = true 36 | analyzer.processMatchStart() 37 | } 38 | }) 39 | }) 40 | }) 41 | 42 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 43 | 44 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 45 | 46 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 47 | 48 | parser.RegisterEventHandler(func(event events.AnnouncementWinPanelMatch) { 49 | analyzer.updatePlayersScores() 50 | matchStarted = false 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/api/flashbang_explode.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 8 | ) 9 | 10 | type FlashbangExplode struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 20 | ThrowerName string `json:"throwerName"` 21 | ThrowerSide common.Team `json:"throwerSide"` 22 | ThrowerTeamName string `json:"throwerTeamName"` 23 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 24 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 25 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 26 | ThrowerPitch float32 `json:"throwerPitch"` 27 | ThrowerYaw float32 `json:"throwerYaw"` 28 | } 29 | 30 | func newFlashbangExplodeFromGameEvent(analyzer *Analyzer, event events.FlashExplode) *FlashbangExplode { 31 | grenade := event.Grenade 32 | if grenade == nil { 33 | fmt.Println("Grenade nil in flashbang explode event") 34 | return nil 35 | } 36 | 37 | thrower := event.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in flashbang explode event") 40 | return nil 41 | } 42 | 43 | throwerTeam := thrower.Team 44 | parser := analyzer.parser 45 | var projectileID int64 46 | for _, projectile := range parser.GameState().GrenadeProjectiles() { 47 | if projectile.WeaponInstance.UniqueID2() == grenade.UniqueID2() { 48 | projectileID = projectile.UniqueID() 49 | } 50 | } 51 | 52 | velocity := thrower.Velocity() 53 | 54 | return &FlashbangExplode{ 55 | Frame: parser.CurrentFrame(), 56 | Tick: analyzer.currentTick(), 57 | RoundNumber: analyzer.currentRound.Number, 58 | GrenadeID: grenade.UniqueID2().String(), 59 | ProjectileID: projectileID, 60 | X: event.Position.X, 61 | Y: event.Position.Y, 62 | Z: event.Position.Z, 63 | ThrowerSteamID64: thrower.SteamID64, 64 | ThrowerName: thrower.Name, 65 | ThrowerSide: throwerTeam, 66 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 67 | ThrowerVelocityX: velocity.X, 68 | ThrowerVelocityY: velocity.Y, 69 | ThrowerVelocityZ: velocity.Z, 70 | ThrowerYaw: thrower.ViewDirectionX(), 71 | ThrowerPitch: thrower.ViewDirectionY(), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/api/grenade_bounce.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | ) 9 | 10 | type GrenadeBounce struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | GrenadeName constants.WeaponName `json:"grenadeName"` 17 | X float64 `json:"x"` 18 | Y float64 `json:"y"` 19 | Z float64 `json:"z"` 20 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 21 | ThrowerName string `json:"throwerName"` 22 | ThrowerSide common.Team `json:"throwerSide"` 23 | ThrowerTeamName string `json:"throwerTeamName"` 24 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 25 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 26 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 27 | ThrowerPitch float32 `json:"throwerPitch"` 28 | ThrowerYaw float32 `json:"throwerYaw"` 29 | } 30 | 31 | func newGrenadeBounceFromProjectile(analyzer *Analyzer, projectile *common.GrenadeProjectile) *GrenadeBounce { 32 | if projectile == nil { 33 | fmt.Println("Projectile nil in grenade projectile bounce event") 34 | return nil 35 | } 36 | 37 | if projectile.WeaponInstance == nil { 38 | fmt.Println("Projectile weapon instance nil in grenade projectile bounce event") 39 | return nil 40 | } 41 | 42 | thrower := projectile.Thrower 43 | if thrower == nil { 44 | fmt.Println("Thrower nil in grenade projectile bounce event, falling back to owner") 45 | thrower = projectile.WeaponInstance.Owner 46 | if thrower == nil { 47 | fmt.Println("Owner nil in grenade projectile bounce event") 48 | return nil 49 | } 50 | } 51 | 52 | velocity := thrower.Velocity() 53 | 54 | parser := analyzer.parser 55 | throwerTeam := thrower.Team 56 | return &GrenadeBounce{ 57 | Frame: parser.CurrentFrame(), 58 | Tick: analyzer.currentTick(), 59 | RoundNumber: analyzer.currentRound.Number, 60 | GrenadeID: projectile.WeaponInstance.UniqueID2().String(), 61 | ProjectileID: projectile.UniqueID(), 62 | GrenadeName: equipmentToWeaponName[projectile.WeaponInstance.Type], 63 | X: projectile.Position().X, 64 | Y: projectile.Position().Y, 65 | Z: projectile.Position().Z, 66 | ThrowerSteamID64: thrower.SteamID64, 67 | ThrowerName: thrower.Name, 68 | ThrowerSide: throwerTeam, 69 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 70 | ThrowerVelocityX: velocity.X, 71 | ThrowerVelocityY: velocity.Y, 72 | ThrowerVelocityZ: velocity.Z, 73 | ThrowerYaw: thrower.ViewDirectionX(), 74 | ThrowerPitch: thrower.ViewDirectionY(), 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/api/grenade_position.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | ) 9 | 10 | type GrenadePosition struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 20 | ThrowerName string `json:"throwerName"` 21 | ThrowerSide common.Team `json:"throwerSide"` 22 | ThrowerTeamName string `json:"throwerTeamName"` 23 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 24 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 25 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 26 | ThrowerPitch float32 `json:"throwerPitch"` 27 | ThrowerYaw float32 `json:"throwerYaw"` 28 | GrenadeName constants.WeaponName `json:"grenadeName"` 29 | } 30 | 31 | func newGrenadePositionFromProjectile(analyzer *Analyzer, projectile *common.GrenadeProjectile) *GrenadePosition { 32 | if projectile.WeaponInstance == nil { 33 | fmt.Println("Projectile weapon instance nil in grenade projectile position") 34 | return nil 35 | } 36 | 37 | thrower := projectile.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in grenade projectile position, falling back to owner") 40 | thrower = projectile.WeaponInstance.Owner 41 | if thrower == nil { 42 | fmt.Println("Owner nil in grenade projectile position") 43 | return nil 44 | } 45 | } 46 | 47 | velocity := thrower.Velocity() 48 | 49 | parser := analyzer.parser 50 | throwerTeam := thrower.Team 51 | return &GrenadePosition{ 52 | Frame: parser.CurrentFrame(), 53 | Tick: analyzer.currentTick(), 54 | RoundNumber: analyzer.currentRound.Number, 55 | GrenadeID: projectile.WeaponInstance.UniqueID2().String(), 56 | ProjectileID: projectile.UniqueID(), 57 | GrenadeName: equipmentToWeaponName[projectile.WeaponInstance.Type], 58 | X: projectile.Position().X, 59 | Y: projectile.Position().Y, 60 | Z: projectile.Position().Z, 61 | ThrowerSteamID64: thrower.SteamID64, 62 | ThrowerName: thrower.Name, 63 | ThrowerSide: throwerTeam, 64 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 65 | ThrowerVelocityX: velocity.X, 66 | ThrowerVelocityY: velocity.Y, 67 | ThrowerVelocityZ: velocity.Z, 68 | ThrowerYaw: thrower.ViewDirectionX(), 69 | ThrowerPitch: thrower.ViewDirectionY(), 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/api/grenade_projectile_destroy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | ) 9 | 10 | type GrenadeProjectileDestroy struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | GrenadeName constants.WeaponName `json:"grenadeName"` 17 | X float64 `json:"x"` 18 | Y float64 `json:"y"` 19 | Z float64 `json:"z"` 20 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 21 | ThrowerName string `json:"throwerName"` 22 | ThrowerSide common.Team `json:"throwerSide"` 23 | ThrowerTeamName string `json:"throwerTeamName"` 24 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 25 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 26 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 27 | ThrowerPitch float32 `json:"throwerPitch"` 28 | ThrowerYaw float32 `json:"throwerYaw"` 29 | } 30 | 31 | func newGrenadeProjectileDestroyFromProjectile(analyzer *Analyzer, projectile *common.GrenadeProjectile) *GrenadeProjectileDestroy { 32 | if projectile == nil { 33 | fmt.Println("Projectile nil in grenade projectile destroy creation") 34 | return nil 35 | } 36 | 37 | thrower := projectile.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in grenade projectile destroy creation, falling back to owner") 40 | if projectile.WeaponInstance == nil { 41 | fmt.Println("Projectile weapon instance nil in grenade projectile destroy creation") 42 | return nil 43 | } 44 | 45 | thrower = projectile.WeaponInstance.Owner 46 | if thrower == nil { 47 | fmt.Println("Owner nil in grenade projectile destroy creation") 48 | return nil 49 | } 50 | } 51 | 52 | velocity := thrower.Velocity() 53 | 54 | parser := analyzer.parser 55 | throwerTeam := thrower.Team 56 | return &GrenadeProjectileDestroy{ 57 | Frame: parser.CurrentFrame(), 58 | Tick: analyzer.currentTick(), 59 | RoundNumber: analyzer.currentRound.Number, 60 | GrenadeID: projectile.WeaponInstance.UniqueID2().String(), 61 | ProjectileID: projectile.UniqueID(), 62 | GrenadeName: equipmentToWeaponName[projectile.WeaponInstance.Type], 63 | X: projectile.Position().X, 64 | Y: projectile.Position().Y, 65 | Z: projectile.Position().Z, 66 | ThrowerSteamID64: thrower.SteamID64, 67 | ThrowerName: thrower.Name, 68 | ThrowerSide: throwerTeam, 69 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 70 | ThrowerVelocityX: velocity.X, 71 | ThrowerVelocityY: velocity.Y, 72 | ThrowerVelocityZ: velocity.Z, 73 | ThrowerYaw: thrower.ViewDirectionX(), 74 | ThrowerPitch: thrower.ViewDirectionY(), 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/api/he_grenade_explode.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 8 | ) 9 | 10 | type HeGrenadeExplode struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 20 | ThrowerName string `json:"throwerName"` 21 | ThrowerSide common.Team `json:"throwerSide"` 22 | ThrowerTeamName string `json:"throwerTeamName"` 23 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 24 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 25 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 26 | ThrowerPitch float32 `json:"throwerPitch"` 27 | ThrowerYaw float32 `json:"throwerYaw"` 28 | } 29 | 30 | func newHeGrenadeExplodeFromGameEvent(analyzer *Analyzer, event events.HeExplode) *HeGrenadeExplode { 31 | grenade := event.Grenade 32 | if grenade == nil { 33 | fmt.Println("Grenade nil in HE grenade explode event") 34 | return nil 35 | } 36 | 37 | thrower := event.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in HE grenade explode event") 40 | return nil 41 | } 42 | 43 | throwerTeam := thrower.Team 44 | parser := analyzer.parser 45 | var projectileID int64 46 | for _, projectile := range parser.GameState().GrenadeProjectiles() { 47 | if projectile.WeaponInstance.UniqueID2() == grenade.UniqueID2() { 48 | projectileID = projectile.UniqueID() 49 | } 50 | } 51 | 52 | velocity := thrower.Velocity() 53 | 54 | return &HeGrenadeExplode{ 55 | Frame: parser.CurrentFrame(), 56 | Tick: analyzer.currentTick(), 57 | RoundNumber: analyzer.currentRound.Number, 58 | GrenadeID: grenade.UniqueID2().String(), 59 | ProjectileID: projectileID, 60 | X: event.Position.X, 61 | Y: event.Position.Y, 62 | Z: event.Position.Z, 63 | ThrowerSteamID64: thrower.SteamID64, 64 | ThrowerName: thrower.Name, 65 | ThrowerSide: throwerTeam, 66 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 67 | ThrowerVelocityX: velocity.X, 68 | ThrowerVelocityY: velocity.Y, 69 | ThrowerVelocityZ: velocity.Z, 70 | ThrowerYaw: thrower.ViewDirectionX(), 71 | ThrowerPitch: thrower.ViewDirectionY(), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/api/hostage_pick_up_start.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type HostagePickUpStart struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | PlayerSteamID64 uint64 `json:"playerSteamId"` 12 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 13 | HostageEntityId int `json:"hostageEntityId"` 14 | X float64 `json:"x"` 15 | Y float64 `json:"y"` 16 | Z float64 `json:"z"` 17 | } 18 | 19 | func newHostagePickupStart(analyzer *Analyzer, player *common.Player, hostage *common.Hostage) *HostagePickUpStart { 20 | parser := analyzer.parser 21 | 22 | return &HostagePickUpStart{ 23 | Frame: parser.CurrentFrame(), 24 | Tick: analyzer.currentTick(), 25 | RoundNumber: analyzer.currentRound.Number, 26 | PlayerSteamID64: player.SteamID64, 27 | IsPlayerControllingBot: player.IsControllingBot(), 28 | HostageEntityId: hostage.Entity.ID(), 29 | X: hostage.Position().X, 30 | Y: hostage.Position().Y, 31 | Z: hostage.Position().Z, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/api/hostage_picked_up.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type HostagePickedUp struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | HostageEntityId int `json:"hostageEntityId"` 12 | PlayerSteamID64 uint64 `json:"playerSteamId"` 13 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 14 | X float64 `json:"x"` 15 | Y float64 `json:"y"` 16 | Z float64 `json:"z"` 17 | } 18 | 19 | func newHostagePickedUp(analyzer *Analyzer, hostage *common.Hostage) *HostagePickedUp { 20 | parser := analyzer.parser 21 | 22 | return &HostagePickedUp{ 23 | Frame: parser.CurrentFrame(), 24 | Tick: analyzer.currentTick(), 25 | RoundNumber: analyzer.currentRound.Number, 26 | PlayerSteamID64: hostage.Leader().SteamID64, 27 | IsPlayerControllingBot: hostage.Leader().IsControllingBot(), 28 | HostageEntityId: hostage.Entity.ID(), 29 | X: hostage.Position().X, 30 | Y: hostage.Position().Y, 31 | Z: hostage.Position().Z, 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /pkg/api/hostage_position.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type HostagePosition struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | X float64 `json:"x"` 12 | Y float64 `json:"y"` 13 | Z float64 `json:"z"` 14 | State common.HostageState `json:"state"` 15 | } 16 | 17 | func newHostagePositionFromHostage(analyzer *Analyzer, hostage *common.Hostage) *HostagePosition { 18 | parser := analyzer.parser 19 | 20 | return &HostagePosition{ 21 | Frame: parser.CurrentFrame(), 22 | Tick: analyzer.currentTick(), 23 | RoundNumber: analyzer.currentRound.Number, 24 | X: hostage.Position().X, 25 | Y: hostage.Position().Y, 26 | Z: hostage.Position().Z, 27 | State: hostage.State(), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/api/hostage_rescued.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | ) 6 | 7 | type HostageRescued struct { 8 | Frame int `json:"frame"` 9 | Tick int `json:"tick"` 10 | RoundNumber int `json:"roundNumber"` 11 | PlayerSteamID64 uint64 `json:"playerSteamId"` 12 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 13 | HostageEntityId int `json:"hostageEntityId"` 14 | X float64 `json:"x"` 15 | Y float64 `json:"y"` 16 | Z float64 `json:"z"` 17 | } 18 | 19 | func newHostageRescued(analyzer *Analyzer, hostage *common.Hostage) *HostageRescued { 20 | parser := analyzer.parser 21 | 22 | return &HostageRescued{ 23 | Frame: parser.CurrentFrame(), 24 | Tick: analyzer.currentTick(), 25 | RoundNumber: analyzer.currentRound.Number, 26 | PlayerSteamID64: hostage.Leader().SteamID64, 27 | IsPlayerControllingBot: hostage.Leader().IsControllingBot(), 28 | HostageEntityId: hostage.Entity.ID(), 29 | X: hostage.Position().X, 30 | Y: hostage.Position().Y, 31 | Z: hostage.Position().Z, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/api/inferno_position.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/golang/geo/r2" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 8 | ) 9 | 10 | type InfernoPosition struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 15 | ThrowerName string `json:"throwerName"` 16 | UniqueID int64 `json:"uniqueId"` 17 | X float64 `json:"x"` 18 | Y float64 `json:"y"` 19 | Z float64 `json:"z"` 20 | ConvexHull2D []r2.Point `json:"convexHull2D"` 21 | } 22 | 23 | func newInfernoPositionFromInferno(analyzer *Analyzer, inferno *common.Inferno) *InfernoPosition { 24 | thrower := inferno.Thrower() 25 | if thrower == nil { 26 | fmt.Println("Thrower nil in inferno") 27 | return nil 28 | } 29 | 30 | parser := analyzer.parser 31 | 32 | return &InfernoPosition{ 33 | Frame: parser.CurrentFrame(), 34 | Tick: analyzer.currentTick(), 35 | RoundNumber: analyzer.currentRound.Number, 36 | UniqueID: inferno.UniqueID(), 37 | ThrowerSteamID64: thrower.SteamID64, 38 | ThrowerName: thrower.Name, 39 | X: inferno.Entity.Position().X, 40 | Y: inferno.Entity.Position().Y, 41 | Z: inferno.Entity.Position().Z, 42 | ConvexHull2D: inferno.Fires().Active().ConvexHull2D(), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/api/kill.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/akiver/cs-demo-analyzer/internal/math" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/golang/geo/r3" 9 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 10 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 11 | ) 12 | 13 | type Kill struct { 14 | Frame int `json:"frame"` 15 | Tick int `json:"tick"` 16 | RoundNumber int `json:"roundNumber"` 17 | WeaponType constants.WeaponType `json:"weaponType"` 18 | WeaponName constants.WeaponName `json:"weaponName"` 19 | KillerName string `json:"killerName"` 20 | KillerSteamID64 uint64 `json:"killerSteamId"` 21 | KillerSide common.Team `json:"killerSide"` 22 | KillerTeamName string `json:"killerTeamName"` 23 | KillerX float64 `json:"killerX"` 24 | KillerY float64 `json:"killerY"` 25 | KillerZ float64 `json:"killerZ"` 26 | IsKillerAirborne bool `json:"is_killer_airborne"` 27 | IsKillerBlinded bool `json:"is_killer_blinded"` 28 | IsKillerControllingBot bool `json:"isKillerControllingBot"` 29 | VictimName string `json:"victimName"` 30 | VictimSteamID64 uint64 `json:"victimSteamId"` 31 | VictimSide common.Team `json:"victimSide"` 32 | VictimTeamName string `json:"victimTeamName"` 33 | VictimX float64 `json:"victimX"` 34 | VictimY float64 `json:"victimY"` 35 | VictimZ float64 `json:"victimZ"` 36 | IsVictimAirborne bool `json:"is_victim_airborne"` 37 | IsVictimBlinded bool `json:"is_victim_blinded"` 38 | IsVictimControllingBot bool `json:"isVictimControllingBot"` 39 | IsVictimInspectingWeapon bool `json:"isVictimInspectingWeapon"` 40 | AssisterName string `json:"assisterName"` 41 | AssisterSteamID64 uint64 `json:"assisterSteamId"` 42 | AssisterSide common.Team `json:"assisterSide"` 43 | AssisterTeamName string `json:"assisterTeamName"` 44 | AssisterX float64 `json:"assisterX"` 45 | AssisterY float64 `json:"assisterY"` 46 | AssisterZ float64 `json:"assisterZ"` 47 | IsAssisterControllingBot bool `json:"isAssisterControllingBot"` 48 | IsHeadshot bool `json:"isHeadshot"` 49 | PenetratedObjects int `json:"penetratedObjects"` 50 | IsAssistedFlash bool `json:"isAssistedFlash"` 51 | IsThroughSmoke bool `json:"isThroughSmoke"` 52 | IsNoScope bool `json:"isNoScope"` 53 | IsTradeKill bool `json:"isTradeKill"` // The attacker did a trade kill 54 | IsTradeDeath bool `json:"isTradeDeath"` // The victim did a trade death 55 | Distance float32 `json:"distance"` 56 | } 57 | 58 | func (kill *Kill) IsSuicide() bool { 59 | return kill.KillerSteamID64 == kill.VictimSteamID64 60 | } 61 | 62 | func (kill *Kill) IsTeamKill() bool { 63 | return kill.KillerSide == kill.VictimSide 64 | } 65 | 66 | func newKillFromGameEvent(analyzer *Analyzer, event events.Kill) *Kill { 67 | if event.Weapon == nil { 68 | fmt.Println("Player kill event without weapon occurred") 69 | return nil 70 | } 71 | if event.Victim == nil { 72 | fmt.Println("Player kill event without victim occurred") 73 | return nil 74 | } 75 | parser := analyzer.parser 76 | match := analyzer.match 77 | var killerName string = "World" 78 | var killerSteamID uint64 79 | var killerSide common.Team 80 | var killerTeamName string 81 | var isKillerControllingBot bool 82 | var isKillerAirborne bool 83 | var isKillerBlinded bool 84 | var killerX float64 85 | var killerY float64 86 | var killerZ float64 87 | if event.Killer != nil { 88 | killerName = event.Killer.Name 89 | killerSteamID = event.Killer.SteamID64 90 | killerSide = event.Killer.Team 91 | killerTeamName = match.Team(event.Killer.Team).Name 92 | isKillerControllingBot = event.Killer.IsControllingBot() 93 | killerX = event.Killer.Position().X 94 | killerY = event.Killer.Position().Y 95 | killerZ = event.Killer.Position().Z 96 | isKillerAirborne = event.Killer.IsAirborne() 97 | isKillerBlinded = event.Killer.IsBlinded() 98 | } 99 | 100 | var isVictimInspectingWeapon bool 101 | if analyzer.isSource2 { 102 | isVictimInspectingWeapon = event.Victim.PlayerPawnEntity().PropertyValueMust("m_pWeaponServices.m_bIsLookingAtWeapon").BoolVal() 103 | } else if event.Victim.Entity != nil { 104 | isVictimInspectingWeapon = event.Victim.Entity.PropertyValueMust("m_bIsLookingAtWeapon").BoolVal() 105 | } 106 | 107 | var isTradeKill bool 108 | for _, kill := range analyzer.match.Kills { 109 | if kill.RoundNumber == analyzer.currentRound.Number && killerSteamID != 0 { 110 | if kill.KillerSteamID64 == event.Victim.SteamID64 && !analyzer.secondsHasPassedSinceTick(tradeKillDelaySeconds, kill.Tick) { 111 | isTradeKill = true 112 | kill.IsTradeDeath = true 113 | } 114 | } 115 | } 116 | 117 | var assisterName string 118 | var assisterSteamID uint64 119 | var assisterSide common.Team 120 | var assisterTeamName string 121 | var isAssisterControllingBot bool 122 | var assisterX float64 123 | var assisterY float64 124 | var assisterZ float64 125 | if event.Assister != nil { 126 | assisterName = event.Assister.Name 127 | assisterSteamID = event.Assister.SteamID64 128 | assisterSide = event.Assister.Team 129 | assisterTeamName = match.Team(event.Assister.Team).Name 130 | isAssisterControllingBot = event.Assister.IsControllingBot() 131 | assisterX = event.Assister.Position().X 132 | assisterY = event.Assister.Position().Y 133 | assisterZ = event.Assister.Position().Z 134 | } 135 | 136 | distance := event.Distance 137 | if distance == 0 && event.Killer != nil && event.Victim != nil { 138 | var killerPosition r3.Vector 139 | var victimPosition r3.Vector 140 | if analyzer.isSource2 { 141 | killerPosition = event.Killer.Position() 142 | victimPosition = event.Victim.Position() 143 | } else { 144 | killerPosition = event.Killer.PositionEyes() 145 | victimPosition = event.Victim.PositionEyes() 146 | } 147 | 148 | distance = float32(math.GetDistanceBetweenVectors(killerPosition, victimPosition)) 149 | } 150 | 151 | victimPosition := event.Victim.Position() 152 | 153 | return &Kill{ 154 | Frame: parser.CurrentFrame(), 155 | Tick: analyzer.currentTick(), 156 | RoundNumber: analyzer.currentRound.Number, 157 | KillerName: killerName, 158 | KillerSteamID64: killerSteamID, 159 | KillerSide: killerSide, 160 | KillerTeamName: killerTeamName, 161 | VictimName: event.Victim.Name, 162 | VictimSteamID64: event.Victim.SteamID64, 163 | VictimSide: event.Victim.Team, 164 | VictimTeamName: match.Team(event.Victim.Team).Name, 165 | AssisterName: assisterName, 166 | AssisterSteamID64: assisterSteamID, 167 | AssisterSide: assisterSide, 168 | AssisterTeamName: assisterTeamName, 169 | IsHeadshot: event.IsHeadshot, 170 | PenetratedObjects: event.PenetratedObjects, 171 | WeaponName: equipmentToWeaponName[event.Weapon.Type], 172 | WeaponType: getEquipmentWeaponType(*event.Weapon), 173 | IsKillerControllingBot: isKillerControllingBot, 174 | IsVictimControllingBot: event.Victim.IsControllingBot(), 175 | IsAssisterControllingBot: isAssisterControllingBot, 176 | KillerX: killerX, 177 | KillerY: killerY, 178 | KillerZ: killerZ, 179 | IsKillerAirborne: isKillerAirborne, 180 | IsKillerBlinded: isKillerBlinded, 181 | VictimX: victimPosition.X, 182 | VictimY: victimPosition.Y, 183 | VictimZ: victimPosition.Z, 184 | IsVictimAirborne: event.Victim.IsAirborne(), 185 | IsVictimBlinded: event.Victim.IsBlinded(), 186 | IsVictimInspectingWeapon: isVictimInspectingWeapon, 187 | AssisterX: assisterX, 188 | AssisterY: assisterY, 189 | AssisterZ: assisterZ, 190 | IsTradeKill: isTradeKill, 191 | IsAssistedFlash: event.AssistedFlash, 192 | IsThroughSmoke: event.ThroughSmoke, 193 | IsNoScope: event.NoScope, 194 | Distance: distance, 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /pkg/api/matchzy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 7 | sendtables "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 8 | ) 9 | 10 | func createMatchZyAnalyzer(analyzer *Analyzer) { 11 | match := analyzer.match 12 | match.gameModeStr = constants.GameModeStrCompetitive 13 | parser := analyzer.parser 14 | matchStarted := true 15 | // Track the end of the game to not re-compute the last round players economy as the freeze time start event is 16 | // triggered before the final round end event. 17 | gameEndTick := -1 18 | // Used to detect when the game is paused and then resumed after a backup restore 19 | isPausedDueToBackupRestore := false 20 | analyzer.matchStarted = func() bool { 21 | return matchStarted 22 | } 23 | 24 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 25 | serverClasses := parser.ServerClasses() 26 | serverClasses.FindByName("CCSTeam").OnEntityCreated(func(entity sendtables.Entity) { 27 | entity.Property("m_szClanTeamname").OnUpdate(func(value sendtables.PropertyValue) { 28 | analyzer.updateTeamNames() 29 | }) 30 | }) 31 | 32 | serverClasses.FindByName("CCSGameRulesProxy").OnEntityCreated(func(entity sendtables.Entity) { 33 | entity.Property("m_pGameRules.m_eRoundEndReason").OnUpdate(func(value sendtables.PropertyValue) { 34 | if value.Int() == int(events.RoundEndReasonDraw) { 35 | roundEndWinnerTeam := entity.PropertyValueMust("m_pGameRules.m_iRoundEndWinnerTeam").Int() 36 | if roundEndWinnerTeam == int(common.TeamSpectators) { 37 | // backup restore, the match is now paused, the match will resume when m_bMatchWaitingForResume is set to false 38 | matchStarted = false 39 | isPausedDueToBackupRestore = true 40 | roundPlayedCount := entity.PropertyValueMust("m_pGameRules.m_totalRoundsPlayed").Int() 41 | if roundPlayedCount == 0 { 42 | analyzer.reset() 43 | } else { 44 | analyzer.resetCurrentRound() 45 | } 46 | } 47 | } 48 | }) 49 | 50 | entity.Property("m_pGameRules.m_bMatchWaitingForResume").OnUpdate(func(value sendtables.PropertyValue) { 51 | // Resume the match when the backup restore is done 52 | if isPausedDueToBackupRestore && !value.BoolVal() { 53 | matchStarted = true 54 | isPausedDueToBackupRestore = false 55 | currentRound := analyzer.currentRound 56 | currentRound.StartFrame = parser.CurrentFrame() 57 | currentRound.StartTick = analyzer.currentTick() 58 | } 59 | }) 60 | 61 | entity.Property("m_pGameRules.m_gamePhase").OnUpdate(func(value sendtables.PropertyValue) { 62 | if value.Int() == int(common.GamePhaseGameEnded) { 63 | gameEndTick = analyzer.currentTick() 64 | } 65 | }) 66 | }) 67 | 68 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 69 | analyzer.registerUnknownPlayers() 70 | matchStarted = event.NewIsStarted 71 | if matchStarted { 72 | analyzer.reset() 73 | currentRound := analyzer.currentRound 74 | currentRound.StartFrame = parser.CurrentFrame() 75 | currentRound.StartTick = analyzer.currentTick() 76 | } else { 77 | matchStarted = false 78 | } 79 | }) 80 | 81 | parser.RegisterEventHandler(func(event events.RoundFreezetimeChanged) { 82 | if !analyzer.matchStarted() || gameEndTick == analyzer.currentTick() { 83 | return 84 | } 85 | 86 | // It may not be accurate to create players economy on round start because it's possible to buy 87 | // a few ticks before the round start event and so may results in incorrect values. 88 | // Do it when the freeze time starts, it's updated just before round start events. 89 | if event.NewIsFreezetime { 90 | analyzer.createPlayersEconomies() 91 | } else { 92 | analyzer.currentRound.FreezeTimeEndTick = analyzer.currentTick() 93 | analyzer.currentRound.FreezeTimeEndFrame = parser.CurrentFrame() 94 | analyzer.lastFreezeTimeEndTick = analyzer.currentTick() 95 | } 96 | }) 97 | }) 98 | 99 | parser.RegisterEventHandler(func(event events.RoundStart) { 100 | if !analyzer.matchStarted() || analyzer.currentTick() == 0 || len(match.Rounds) == 0 { 101 | return 102 | } 103 | 104 | analyzer.createRound() 105 | }) 106 | 107 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 108 | 109 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/api/player_buy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 7 | ) 8 | 9 | type PlayerBuy struct { 10 | Frame int `json:"frame"` 11 | Tick int `json:"tick"` 12 | RoundNumber int `json:"roundNumber"` 13 | PlayerSteamID64 uint64 `json:"playerSteamId"` 14 | PlayerSide common.Team `json:"playerSide"` 15 | PlayerName string `json:"playerName"` 16 | WeaponName constants.WeaponName `json:"weaponName"` 17 | WeaponType constants.WeaponType `json:"weaponType"` 18 | WeaponUniqueID string `json:"weaponUniqueId"` 19 | HasRefunded bool `json:"hasRefunded"` 20 | } 21 | 22 | func newPlayerBuy(analyzer *Analyzer, event events.ItemPickup) *PlayerBuy { 23 | parser := analyzer.parser 24 | 25 | return &PlayerBuy{ 26 | Frame: parser.CurrentFrame(), 27 | Tick: analyzer.currentTick(), 28 | RoundNumber: analyzer.currentRound.Number, 29 | PlayerSteamID64: event.Player.SteamID64, 30 | PlayerName: event.Player.Name, 31 | PlayerSide: event.Player.Team, 32 | WeaponName: equipmentToWeaponName[event.Weapon.Type], 33 | WeaponType: getEquipmentWeaponType(*event.Weapon), 34 | WeaponUniqueID: event.Weapon.UniqueID2().String(), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/api/player_economy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | ) 7 | 8 | type PlayerEconomy struct { 9 | RoundNumber int `json:"roundNumber"` 10 | Name string `json:"name"` 11 | SteamID64 uint64 `json:"steamId"` 12 | StartMoney int `json:"startMoney"` 13 | MoneySpent int `json:"moneySpent"` 14 | EquipmentValue int `json:"equipmentValue"` 15 | Type constants.EconomyType `json:"type"` 16 | PlayerSide common.Team `json:"playerSide"` 17 | } 18 | 19 | func newPlayerEconomy(analyzer *Analyzer, player *common.Player) *PlayerEconomy { 20 | startMoney := player.Money() 21 | // eBot demos may start just after the end of the 1st round freeze time. 22 | // As a result to get the correct start money, we need to sum the current money + the money spent during the round. 23 | if analyzer.currentRound.Number == 1 { 24 | startMoney += player.MoneySpentThisRound() 25 | } 26 | 27 | economy := &PlayerEconomy{ 28 | RoundNumber: analyzer.currentRound.Number, 29 | SteamID64: player.SteamID64, 30 | Name: player.Name, 31 | StartMoney: startMoney, 32 | EquipmentValue: player.EquipmentValueCurrent(), 33 | MoneySpent: player.MoneySpentThisRound(), 34 | Type: computePlayerEconomyType(analyzer, player), 35 | PlayerSide: player.Team, 36 | } 37 | 38 | return economy 39 | } 40 | 41 | func (economy *PlayerEconomy) updateValues(analyzer *Analyzer, player *common.Player) { 42 | economy.PlayerSide = player.Team 43 | economy.EquipmentValue = player.EquipmentValueCurrent() 44 | economy.MoneySpent = player.MoneySpentThisRound() 45 | economy.Type = computePlayerEconomyType(analyzer, player) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/api/player_flashed.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | ) 7 | 8 | type PlayerFlashed struct { 9 | Frame int `json:"frame"` 10 | Tick int `json:"tick"` 11 | RoundNumber int `json:"roundNumber"` 12 | Duration float32 `json:"duration"` 13 | FlashedSteamID64 uint64 `json:"flashedSteamId"` 14 | FlashedName string `json:"flashedName"` 15 | FlashedSide common.Team `json:"flashedSide"` 16 | IsFlashedControllingBot bool `json:"isFlashedControllingBot"` 17 | FlasherSteamID64 uint64 `json:"flasherSteamId"` 18 | FlasherName string `json:"flasherName"` 19 | FlasherSide common.Team `json:"flasherSide"` 20 | IsFlasherControllingBot bool `json:"isFlasherControllingBot"` 21 | } 22 | 23 | func newPlayerFlashed(analyzer *Analyzer, event events.PlayerFlashed) *PlayerFlashed { 24 | parser := analyzer.parser 25 | 26 | return &PlayerFlashed{ 27 | Frame: parser.CurrentFrame(), 28 | Tick: analyzer.currentTick(), 29 | RoundNumber: analyzer.currentRound.Number, 30 | Duration: event.Player.FlashDuration, 31 | FlashedName: event.Player.Name, 32 | FlashedSteamID64: event.Player.SteamID64, 33 | FlashedSide: event.Player.Team, 34 | IsFlashedControllingBot: event.Player.IsControllingBot(), 35 | FlasherName: event.Attacker.Name, 36 | FlasherSteamID64: event.Attacker.SteamID64, 37 | FlasherSide: event.Attacker.Team, 38 | IsFlasherControllingBot: event.Attacker.IsControllingBot(), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/api/player_position.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/internal/slice" 5 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 6 | common "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | ) 8 | 9 | type PlayerPosition struct { 10 | Frame int `json:"frame"` 11 | Tick int `json:"tick"` 12 | RoundNumber int `json:"roundNumber"` 13 | IsAlive bool `json:"isAlive"` 14 | Name string `json:"name"` 15 | SteamID64 uint64 `json:"steamId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | Yaw float32 `json:"yaw"` 20 | FlashDurationRemaining float64 `json:"flashDurationRemaining"` 21 | Side common.Team `json:"side"` 22 | Money int `json:"money"` 23 | Health int `json:"health"` 24 | Armor int `json:"armor"` 25 | HasHelmet bool `json:"hasHelmet"` 26 | HasBomb bool `json:"hasBomb"` 27 | HasDefuseKit bool `json:"hasDefuseKit"` 28 | IsDucking bool `json:"isDucking"` 29 | IsAirborne bool `json:"isAirborne"` 30 | IsScoping bool `json:"isScoping"` 31 | IsDefusing bool `json:"isDefusing"` 32 | IsPlanting bool `json:"isPlanting"` 33 | IsGrabbingHostage bool `json:"isGrabbingHostage"` 34 | ActiveWeaponName constants.WeaponName `json:"activeWeaponName"` 35 | Equipments []constants.WeaponName `json:"equipments"` 36 | Grenades []constants.WeaponName `json:"grenades"` 37 | Pistols []constants.WeaponName `json:"pistols"` 38 | SMGs []constants.WeaponName `json:"smgs"` 39 | Rifles []constants.WeaponName `json:"rifles"` 40 | Heavy []constants.WeaponName `json:"heavy"` 41 | } 42 | 43 | func newPlayerPosition(analyzer *Analyzer, player *common.Player) *PlayerPosition { 44 | parser := analyzer.parser 45 | hasBomb := false 46 | var equipments []constants.WeaponName 47 | var grenades []constants.WeaponName 48 | var pistols []constants.WeaponName 49 | var smgs []constants.WeaponName 50 | var rifles []constants.WeaponName 51 | var heavy []constants.WeaponName 52 | for _, weapon := range player.Weapons() { 53 | weaponName := equipmentToWeaponName[weapon.Type] 54 | // Weird bug encountered with a demo from 2017. 55 | // Player's weapons may contains duplicates, add it only if it has not been added yet. 56 | switch weapon.Class() { 57 | case common.EqClassEquipment: 58 | equipments = slice.AppendIfNotInSlice(equipments, weaponName) 59 | case common.EqClassGrenade: 60 | grenades = slice.AppendIfNotInSlice(grenades, weaponName) 61 | case common.EqClassPistols: 62 | pistols = slice.AppendIfNotInSlice(pistols, weaponName) 63 | case common.EqClassSMG: 64 | smgs = slice.AppendIfNotInSlice(smgs, weaponName) 65 | case common.EqClassRifle: 66 | rifles = slice.AppendIfNotInSlice(rifles, weaponName) 67 | case common.EqClassHeavy: 68 | heavy = slice.AppendIfNotInSlice(heavy, weaponName) 69 | } 70 | 71 | if weapon.Type == common.EqBomb { 72 | hasBomb = true 73 | } 74 | } 75 | 76 | activeWeapon := constants.WeaponUnknown 77 | if player.ActiveWeapon() != nil { 78 | activeWeapon = equipmentToWeaponName[player.ActiveWeapon().Type] 79 | } 80 | 81 | return &PlayerPosition{ 82 | Frame: parser.CurrentFrame(), 83 | Tick: analyzer.currentTick(), 84 | RoundNumber: analyzer.currentRound.Number, 85 | Name: player.Name, 86 | SteamID64: player.SteamID64, 87 | IsAlive: player.IsAlive(), 88 | X: player.Position().X, 89 | Y: player.Position().Y, 90 | Z: player.Position().Z, 91 | Yaw: player.ViewDirectionX(), 92 | FlashDurationRemaining: player.FlashDurationTimeRemaining().Seconds(), 93 | Side: player.Team, 94 | Money: player.Money(), 95 | Health: player.Health(), 96 | Armor: player.Armor(), 97 | HasHelmet: player.HasHelmet(), 98 | HasBomb: hasBomb, 99 | HasDefuseKit: player.HasDefuseKit(), 100 | IsDucking: player.IsDucking(), 101 | IsAirborne: player.IsAirborne(), 102 | IsScoping: player.IsScoped(), 103 | IsDefusing: player.IsDefusing, 104 | IsPlanting: player.IsPlanting, 105 | IsGrabbingHostage: player.IsGrabbingHostage(), 106 | ActiveWeaponName: activeWeapon, 107 | Equipments: equipments, 108 | Grenades: grenades, 109 | Pistols: pistols, 110 | SMGs: smgs, 111 | Rifles: rifles, 112 | Heavy: heavy, 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/api/renown.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | st "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 7 | ) 8 | 9 | func createRenownAnalyzer(analyzer *Analyzer) { 10 | parser := analyzer.parser 11 | match := analyzer.match 12 | match.gameModeStr = constants.GameModeStrCompetitive 13 | matchStarted := false 14 | isGamePaused := false 15 | 16 | analyzer.matchStarted = func() bool { 17 | return matchStarted 18 | } 19 | 20 | parser.RegisterEventHandler(func(events.DataTablesParsed) { 21 | parser.ServerClasses().FindByName("CCSGameRulesProxy").OnEntityCreated(func(entity st.Entity) { 22 | entity.Property("m_pGameRules.m_bTechnicalTimeOut").OnUpdate(func(val st.PropertyValue) { 23 | isGamePaused = val.BoolVal() 24 | }) 25 | }) 26 | }) 27 | 28 | parser.RegisterEventHandler(func(event events.PlayerConnect) { 29 | if isGamePaused { 30 | analyzer.createOrUpdatePlayerEconomy(event.Player) 31 | } 32 | }) 33 | 34 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 35 | matchStarted = event.NewIsStarted 36 | if matchStarted { 37 | analyzer.processMatchStart() 38 | } 39 | }) 40 | 41 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 42 | 43 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 44 | 45 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 46 | 47 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/api/round.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 9 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 10 | ) 11 | 12 | type Round struct { 13 | analyzer *Analyzer 14 | Number int `json:"number"` 15 | StartTick int `json:"startTick"` 16 | StartFrame int `json:"startFrame"` 17 | FreezeTimeEndTick int `json:"freezeTimeEndTick"` 18 | FreezeTimeEndFrame int `json:"freezeTimeEndFrame"` 19 | EndTick int `json:"endTick"` 20 | EndFrame int `json:"endFrame"` 21 | EndOfficiallyTick int `json:"endOfficiallyTick"` 22 | EndOfficiallyFrame int `json:"endOfficiallyFrame"` 23 | OvertimeNumber int `json:"overtimeNumber"` 24 | TeamAName string `json:"teamAName"` 25 | TeamBName string `json:"teamBName"` 26 | TeamAScore int `json:"teamAScore"` 27 | TeamBScore int `json:"teamBScore"` 28 | TeamASide common.Team `json:"teamASide"` 29 | TeamBSide common.Team `json:"teamBSide"` 30 | TeamAEquipmentValue int `json:"teamAEquipmentValue"` 31 | TeamBEquipmentValue int `json:"teamBEquipmentValue"` 32 | TeamAMoneySpent int `json:"teamAMoneySpent"` 33 | TeamBMoneySpent int `json:"teamBmoneySpent"` 34 | TeamAEconomyType constants.EconomyType `json:"teamAEconomyType"` 35 | TeamBEconomyType constants.EconomyType `json:"teamBEconomyType"` 36 | Duration int64 `json:"duration"` 37 | EndReason events.RoundEndReason `json:"endReason"` 38 | WinnerName string `json:"winnerName"` 39 | WinnerSide common.Team `json:"winnerSide"` 40 | // Used to detect weapons bought by players during buy time. 41 | // There is no "player buy" event available, instead we use the "item_pickup" event which occurs when a player pickup a weapon at any time in the game. 42 | // Since it's possible to buy and drop a weapon to a teammate and so trigger a new "item_pickup" event, it would result in a wrong weapon buy detection. 43 | // To prevent it, this slice contains all unique weapons ids that players had at the beginning of the round and it's updated when a player buy a weapon. 44 | weaponsBoughtUniqueIds []string `json:"-"` 45 | } 46 | 47 | type RoundAlias Round 48 | 49 | type RoundJSON struct { 50 | *RoundAlias 51 | StartMoneyTeamA int `json:"teamAStartMoney"` 52 | StartMoneyTeamB int `json:"teamBStartMoney"` 53 | } 54 | 55 | func (round *Round) MarshalJSON() ([]byte, error) { 56 | return json.Marshal(RoundJSON{ 57 | RoundAlias: (*RoundAlias)(round), 58 | StartMoneyTeamA: round.StartMoneyTeamA(), 59 | StartMoneyTeamB: round.StartMoneyTeamB(), 60 | }) 61 | } 62 | 63 | func (round *Round) StartMoneyTeamA() int { 64 | return round.getTeamStartMoney(round.TeamASide) 65 | } 66 | 67 | func (round *Round) StartMoneyTeamB() int { 68 | return round.getTeamStartMoney(round.TeamBSide) 69 | } 70 | 71 | // This indicates if x seconds have passed since the beginning of the round. 72 | func (round *Round) secondsPassedSinceRoundStart(seconds int) bool { 73 | parser := round.analyzer.parser 74 | 75 | return float64(parser.GameState().IngameTick()-round.StartTick)*parser.TickTime().Seconds() >= float64(seconds) 76 | } 77 | 78 | func (round *Round) getTeamStartMoney(side common.Team) int { 79 | total := 0 80 | for _, economy := range round.analyzer.match.PlayerEconomies { 81 | if economy.RoundNumber == round.Number && economy.PlayerSide == side { 82 | total += economy.StartMoney 83 | } 84 | } 85 | 86 | return total 87 | } 88 | 89 | func (round *Round) computeTeamsEconomy() { 90 | analyzer := round.analyzer 91 | match := analyzer.match 92 | gameState := analyzer.parser.GameState() 93 | 94 | stateTeamA := gameState.Team(*match.TeamA.CurrentSide) 95 | round.TeamAMoneySpent = stateTeamA.MoneySpentThisRound() 96 | round.TeamAEquipmentValue = stateTeamA.CurrentEquipmentValue() 97 | round.TeamAEconomyType = computeTeamEconomyType(analyzer, stateTeamA) 98 | 99 | stateTeamB := gameState.Team(*match.TeamB.CurrentSide) 100 | round.TeamBMoneySpent = stateTeamB.MoneySpentThisRound() 101 | round.TeamBEquipmentValue = stateTeamB.CurrentEquipmentValue() 102 | round.TeamBEconomyType = computeTeamEconomyType(analyzer, stateTeamB) 103 | } 104 | 105 | // Possible round end reason value reported on round_end event which means that round end reason has not been detected properly. 106 | const roundEndReasonUnassigned = 0 107 | 108 | // This returns the RoundEndReason from the round_end string message event. 109 | // Necessary for old demos that contains unassigned end round reason value (i.e. 0). 110 | func getEndReasonFromRoundEndMessage(message string) events.RoundEndReason { 111 | switch message { 112 | case "#SFUI_Notice_Target_Saved": 113 | return events.RoundEndReasonTargetSaved 114 | case "#SFUI_Notice_Target_Bombed": 115 | return events.RoundEndReasonTargetBombed 116 | default: 117 | return roundEndReasonUnassigned 118 | } 119 | } 120 | 121 | func (round *Round) updateEndReasonFromRoundEndEvent(event events.RoundEnd, mapName string) { 122 | round.EndReason = event.Reason 123 | 124 | if round.EndReason == roundEndReasonUnassigned { 125 | round.EndReason = getEndReasonFromRoundEndMessage(event.Message) 126 | } 127 | 128 | // Encountered with a demo from 2014. 129 | // When CTs won because the bomb exploded and the Ts saved their weapons, the reason may be "HostagesRescued" instead of "TargetSaved" on defuse maps. 130 | if round.EndReason == events.RoundEndReasonHostagesRescued && strings.HasPrefix(mapName, "de_") { 131 | round.EndReason = events.RoundEndReasonTargetSaved 132 | } 133 | } 134 | 135 | func newRound(number int, analyzer *Analyzer) *Round { 136 | // Consider all current players weapons as "already bought" so if a player drop his weapon to a teammate, it will not be detected as a buy. 137 | var weaponsIds []string 138 | for _, playingPlayer := range analyzer.parser.GameState().Participants().Playing() { 139 | for _, weapon := range playingPlayer.Weapons() { 140 | weaponsIds = append(weaponsIds, weapon.UniqueID2().String()) 141 | } 142 | } 143 | 144 | round := &Round{ 145 | analyzer: analyzer, 146 | Number: number, 147 | StartFrame: analyzer.parser.CurrentFrame(), 148 | StartTick: analyzer.currentTick(), 149 | FreezeTimeEndFrame: -1, 150 | FreezeTimeEndTick: -1, 151 | OvertimeNumber: analyzer.match.OvertimeCount, 152 | TeamAName: analyzer.match.TeamA.Name, 153 | TeamAScore: analyzer.match.TeamA.Score, 154 | TeamBName: analyzer.match.TeamB.Name, 155 | TeamBScore: analyzer.match.TeamB.Score, 156 | TeamASide: *analyzer.match.TeamA.CurrentSide, 157 | TeamBSide: *analyzer.match.TeamB.CurrentSide, 158 | weaponsBoughtUniqueIds: weaponsIds, 159 | } 160 | round.computeTeamsEconomy() 161 | 162 | return round 163 | } 164 | -------------------------------------------------------------------------------- /pkg/api/shot.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/golang/geo/r3" 6 | common "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 8 | st "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/sendtables" 9 | ) 10 | 11 | type Shot struct { 12 | Frame int `json:"frame"` 13 | Tick int `json:"tick"` 14 | RoundNumber int `json:"roundNumber"` 15 | WeaponName constants.WeaponName `json:"weaponName"` 16 | WeaponID string `json:"weaponId"` 17 | ProjectileID int64 `json:"projectileId"` // Available only for grenades 18 | X float64 `json:"x"` 19 | Y float64 `json:"y"` 20 | Z float64 `json:"z"` 21 | PlayerName string `json:"playerName"` 22 | PlayerSteamID64 uint64 `json:"playerSteamId"` 23 | PlayerTeamName string `json:"playerTeamName"` 24 | PlayerSide common.Team `json:"playerSide"` 25 | IsPlayerControllingBot bool `json:"isPlayerControllingBot"` 26 | PlayerVelocityX float64 `json:"playerVelocityX"` 27 | PlayerVelocityY float64 `json:"playerVelocityY"` 28 | PlayerVelocityZ float64 `json:"playerVelocityZ"` 29 | Yaw float32 `json:"yaw"` 30 | Pitch float32 `json:"pitch"` 31 | RecoilIndex float32 `json:"recoilIndex"` 32 | AimPunchAngleX float64 `json:"aimPunchAngleX"` 33 | AimPunchAngleY float64 `json:"aimPunchAngleY"` 34 | ViewPunchAngleX float64 `json:"viewPunchAngleX"` 35 | ViewPunchAngleY float64 `json:"viewPunchAngleY"` 36 | } 37 | 38 | func newShot(analyzer *Analyzer, event events.WeaponFire) *Shot { 39 | shooter := event.Shooter 40 | if shooter == nil { 41 | return nil 42 | } 43 | 44 | var weaponEntity st.Entity 45 | activeWeapon := shooter.ActiveWeapon() 46 | if activeWeapon != nil { 47 | weaponEntity = activeWeapon.Entity 48 | } else if event.Weapon.Entity != nil { 49 | weaponEntity = event.Weapon.Entity 50 | } 51 | 52 | var recoilIndex float32 53 | if weaponEntity != nil { 54 | if prop, exists := weaponEntity.PropertyValue("m_flRecoilIndex"); exists { 55 | recoilIndex = prop.Float() 56 | } 57 | } 58 | 59 | var aimPunchAngle r3.Vector 60 | var viewPunchAngle r3.Vector 61 | if analyzer.isSource2 { 62 | pawnEntity := shooter.PlayerPawnEntity() 63 | aimPunchAngle = pawnEntity.PropertyValueMust("m_aimPunchAngle").R3Vec() 64 | // This prop may not exist with demos coming from the early CS2 limited test 65 | if prop, exists := pawnEntity.PropertyValue("m_pCameraServices.m_vecCsViewPunchAngle"); exists { 66 | viewPunchAngle = prop.R3Vec() 67 | } 68 | } else { 69 | aimPunchAngle = shooter.Entity.PropertyValueMust("localdata.m_Local.m_aimPunchAngle").R3Vec() 70 | viewPunchAngle = shooter.Entity.PropertyValueMust("localdata.m_Local.m_viewPunchAngle").R3Vec() 71 | } 72 | 73 | velocity := shooter.Velocity() 74 | 75 | return &Shot{ 76 | Frame: analyzer.parser.CurrentFrame(), 77 | Tick: analyzer.currentTick(), 78 | RoundNumber: analyzer.currentRound.Number, 79 | WeaponName: equipmentToWeaponName[event.Weapon.Type], 80 | WeaponID: event.Weapon.UniqueID2().String(), 81 | X: shooter.Position().X, 82 | Y: shooter.Position().Y, 83 | Z: shooter.Position().Z, 84 | PlayerName: shooter.Name, 85 | PlayerSteamID64: shooter.SteamID64, 86 | PlayerTeamName: analyzer.match.Team(shooter.Team).Name, 87 | PlayerSide: shooter.Team, 88 | IsPlayerControllingBot: shooter.IsControllingBot(), 89 | PlayerVelocityX: velocity.X, 90 | PlayerVelocityY: velocity.Y, 91 | PlayerVelocityZ: velocity.Z, 92 | Yaw: shooter.ViewDirectionX(), 93 | Pitch: shooter.ViewDirectionY(), 94 | RecoilIndex: recoilIndex, 95 | AimPunchAngleX: aimPunchAngle.X, 96 | AimPunchAngleY: aimPunchAngle.Y, 97 | ViewPunchAngleX: viewPunchAngle.X, 98 | ViewPunchAngleY: viewPunchAngle.Y, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/api/smoke_start.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 7 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 8 | ) 9 | 10 | type SmokeStart struct { 11 | Frame int `json:"frame"` 12 | Tick int `json:"tick"` 13 | RoundNumber int `json:"roundNumber"` 14 | GrenadeID string `json:"grenadeId"` 15 | ProjectileID int64 `json:"projectileId"` 16 | X float64 `json:"x"` 17 | Y float64 `json:"y"` 18 | Z float64 `json:"z"` 19 | ThrowerSteamID64 uint64 `json:"throwerSteamId"` 20 | ThrowerName string `json:"throwerName"` 21 | ThrowerSide common.Team `json:"throwerSide"` 22 | ThrowerTeamName string `json:"throwerTeamName"` 23 | ThrowerVelocityX float64 `json:"throwerVelocityX"` 24 | ThrowerVelocityY float64 `json:"throwerVelocityY"` 25 | ThrowerVelocityZ float64 `json:"throwerVelocityZ"` 26 | ThrowerPitch float32 `json:"throwerPitch"` 27 | ThrowerYaw float32 `json:"throwerYaw"` 28 | } 29 | 30 | func newSmokeStartFromGameEvent(analyzer *Analyzer, event events.SmokeStart) *SmokeStart { 31 | grenade := event.Grenade 32 | if grenade == nil { 33 | fmt.Println("Grenade nil in smoke start event") 34 | return nil 35 | } 36 | 37 | thrower := event.Thrower 38 | if thrower == nil { 39 | fmt.Println("Thrower nil in smoke start event") 40 | return nil 41 | } 42 | 43 | throwerTeam := thrower.Team 44 | parser := analyzer.parser 45 | var projectileID int64 46 | for _, projectile := range parser.GameState().GrenadeProjectiles() { 47 | if projectile.WeaponInstance.UniqueID2() == grenade.UniqueID2() { 48 | projectileID = projectile.UniqueID() 49 | break 50 | } 51 | } 52 | 53 | velocity := thrower.Velocity() 54 | 55 | return &SmokeStart{ 56 | Frame: parser.CurrentFrame(), 57 | Tick: analyzer.currentTick(), 58 | RoundNumber: analyzer.currentRound.Number, 59 | GrenadeID: grenade.UniqueID2().String(), 60 | ProjectileID: projectileID, 61 | X: event.Position.X, 62 | Y: event.Position.Y, 63 | Z: event.Position.Z, 64 | ThrowerSteamID64: thrower.SteamID64, 65 | ThrowerName: thrower.Name, 66 | ThrowerSide: throwerTeam, 67 | ThrowerTeamName: analyzer.match.Team(throwerTeam).Name, 68 | ThrowerVelocityX: velocity.X, 69 | ThrowerVelocityY: velocity.Y, 70 | ThrowerVelocityZ: velocity.Z, 71 | ThrowerYaw: thrower.ViewDirectionX(), 72 | ThrowerPitch: thrower.ViewDirectionY(), 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /pkg/api/team.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | common "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | ) 7 | 8 | type Team struct { 9 | Name string `json:"name"` 10 | Letter constants.TeamLetter `json:"letter"` 11 | Score int `json:"score"` 12 | ScoreFirstHalf int `json:"scoreFirstHalf"` 13 | ScoreSecondHalf int `json:"scoreSecondHalf"` 14 | CurrentSide *common.Team `json:"currentSide"` 15 | } 16 | 17 | func (team *Team) swap() { 18 | if *team.CurrentSide == common.TeamCounterTerrorists { 19 | *team.CurrentSide = common.TeamTerrorists 20 | } else if *team.CurrentSide == common.TeamTerrorists { 21 | *team.CurrentSide = common.TeamCounterTerrorists 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/api/valve.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/msgs2" 7 | ) 8 | 9 | func createValveAnalyzer(analyzer *Analyzer) { 10 | parser := analyzer.parser 11 | match := analyzer.match 12 | analyzer.matchStarted = parser.GameState().IsMatchStarted 13 | 14 | parser.RegisterNetMessageHandler(func(srvInfo *msgs2.CSVCMsg_ServerInfo) { 15 | match.gameModeStr = constants.GameModeStr(srvInfo.GameSessionConfig.GetGamemode()) 16 | }) 17 | 18 | parser.RegisterEventHandler(func(event events.MatchStart) { 19 | currentRound := analyzer.currentRound 20 | currentRound.StartFrame = parser.CurrentFrame() 21 | currentRound.StartTick = analyzer.currentTick() 22 | analyzer.updateTeamNames() 23 | }) 24 | 25 | parser.RegisterEventHandler(func(event events.MatchStartedChanged) { 26 | isMatchStarted := !event.OldIsStarted && event.NewIsStarted 27 | if isMatchStarted { 28 | currentRound := analyzer.currentRound 29 | currentRound.TeamASide = *match.TeamA.CurrentSide 30 | currentRound.TeamBSide = *match.TeamB.CurrentSide 31 | } 32 | }) 33 | 34 | parser.RegisterEventHandler(func(event events.GameHalfEnded) { 35 | analyzer.isFirstRoundOfHalf = true 36 | }) 37 | 38 | parser.RegisterEventHandler(analyzer.defaultRoundFreezetimeChangedHandler) 39 | 40 | parser.RegisterEventHandler(analyzer.defaultRoundStartHandler) 41 | 42 | parser.RegisterEventHandler(analyzer.defaultRoundEndOfficiallyHandler) 43 | 44 | parser.RegisterEventHandler(analyzer.defaultAnnouncementWinPanelMatchHandler) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/weapon.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | ) 7 | 8 | func getEquipmentWeaponType(equipment common.Equipment) constants.WeaponType { 9 | switch equipment.Type { 10 | case common.EqCZ, common.EqDeagle, common.EqDualBerettas, common.EqFiveSeven, common.EqGlock, common.EqP2000, common.EqP250, common.EqRevolver, common.EqTec9, common.EqUSP: 11 | return constants.WeaponTypePistol 12 | case common.EqAK47, common.EqAUG, common.EqFamas, common.EqGalil, common.EqM4A1, common.EqM4A4, common.EqSG553: 13 | return constants.WeaponTypeRifle 14 | case common.EqAWP, common.EqG3SG1, common.EqScar20, common.EqScout: 15 | return constants.WeaponTypeSniper 16 | case common.EqMac10, common.EqMP5, common.EqMP7, common.EqMP9, common.EqP90, common.EqBizon, common.EqUMP: 17 | return constants.WeaponTypeSMG 18 | case common.EqSwag7, common.EqNova, common.EqSawedOff, common.EqXM1014: 19 | return constants.WeaponTypeShotgun 20 | case common.EqM249, common.EqNegev: 21 | return constants.WeaponTypeMachineGun 22 | case common.EqDecoy, common.EqFlash, common.EqHE, common.EqIncendiary, common.EqMolotov, common.EqSmoke: 23 | return constants.WeaponTypeGrenade 24 | case common.EqBomb, common.EqDefuseKit, common.EqKevlar, common.EqHelmet: 25 | return constants.WeaponTypeEquipment 26 | case common.EqKnife, common.EqZeus: 27 | return constants.WeaponTypeMelee 28 | case common.EqWorld: 29 | return constants.WeaponTypeWorld 30 | } 31 | 32 | return constants.WeaponTypeUnknown 33 | } 34 | 35 | var equipmentToWeaponName = map[common.EquipmentType]constants.WeaponName{ 36 | common.EqAK47: constants.WeaponAK47, 37 | common.EqAUG: constants.WeaponAUG, 38 | common.EqAWP: constants.WeaponAWP, 39 | common.EqBomb: constants.WeaponBomb, 40 | common.EqCZ: constants.WeaponCZ75, 41 | common.EqDecoy: constants.WeaponDecoy, 42 | common.EqDefuseKit: constants.WeaponDefuseKit, 43 | common.EqDeagle: constants.WeaponDeagle, 44 | common.EqDualBerettas: constants.WeaponDualBerettas, 45 | common.EqFamas: constants.WeaponFamas, 46 | common.EqFiveSeven: constants.WeaponFiveSeven, 47 | common.EqFlash: constants.WeaponFlashbang, 48 | common.EqG3SG1: constants.WeaponG3SG1, 49 | common.EqGalil: constants.WeaponGalilAR, 50 | common.EqGlock: constants.WeaponGlock, 51 | common.EqHE: constants.WeaponHEGrenade, 52 | common.EqKevlar: constants.WeaponKevlar, 53 | common.EqHelmet: constants.WeaponHelmet, 54 | common.EqKnife: constants.WeaponKnife, 55 | common.EqIncendiary: constants.WeaponIncendiary, 56 | common.EqM249: constants.WeaponM249, 57 | common.EqM4A1: constants.WeaponM4A1, 58 | common.EqM4A4: constants.WeaponM4A4, 59 | common.EqMac10: constants.WeaponMac10, 60 | common.EqSwag7: constants.WeaponMAG7, 61 | common.EqMolotov: constants.WeaponMolotov, 62 | common.EqMP5: constants.WeaponMP5, 63 | common.EqMP7: constants.WeaponMP7, 64 | common.EqMP9: constants.WeaponMP9, 65 | common.EqNegev: constants.WeaponNegev, 66 | common.EqNova: constants.WeaponNova, 67 | common.EqP2000: constants.WeaponP2000, 68 | common.EqP250: constants.WeaponP250, 69 | common.EqP90: constants.WeaponP90, 70 | common.EqBizon: constants.WeaponPPBizon, 71 | common.EqRevolver: constants.WeaponRevolver, 72 | common.EqSawedOff: constants.WeaponSawedOff, 73 | common.EqScar20: constants.WeaponScar20, 74 | common.EqSG553: constants.WeaponSG553, 75 | common.EqSmoke: constants.WeaponSmoke, 76 | common.EqScout: constants.WeaponScout, 77 | common.EqTec9: constants.WeaponTec9, 78 | common.EqUMP: constants.WeaponUMP45, 79 | common.EqUnknown: constants.WeaponUnknown, 80 | common.EqUSP: constants.WeaponUSP, 81 | common.EqWorld: constants.WeaponWorld, 82 | common.EqXM1014: constants.WeaponXM1014, 83 | common.EqZeus: constants.WeaponZeus, 84 | } 85 | -------------------------------------------------------------------------------- /pkg/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/akiver/cs-demo-analyzer/pkg/api" 10 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 11 | ) 12 | 13 | type cliArgs struct { 14 | demoPath string 15 | includePositions bool 16 | source string 17 | outputPath string 18 | format string 19 | minifyJSON bool 20 | } 21 | 22 | func (cli *cliArgs) validateArgs() error { 23 | if cli.demoPath == "" { 24 | return errors.New("demo file path required, example: -demo-path path/to/demo.dem") 25 | } 26 | 27 | if cli.outputPath == "" { 28 | return errors.New("output path required, example: -output ./output") 29 | } 30 | 31 | if cli.format != "" { 32 | err := api.ValidateExportFormat(constants.ExportFormat(cli.format)) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | 38 | if cli.source != "" { 39 | err := api.ValidateDemoSource(constants.DemoSource(cli.source)) 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (cli *cliArgs) fromArgs(args []string) error { 49 | fs := flag.NewFlagSet("csda", flag.ContinueOnError) 50 | fs.StringVar(&cli.demoPath, "demo-path", "", "Demo file path (mandatory)") 51 | fs.StringVar(&cli.outputPath, "output", "", "Output folder or file path, must be a folder when exporting to CSV (mandatory)") 52 | fs.StringVar(&cli.format, "format", "csv", "Export format, valid values: "+api.FormatValidExportFormats()) 53 | fs.StringVar(&cli.source, "source", "", "Force demo's source, valid values: "+api.FormatValidDemoSources()) 54 | fs.BoolVar(&cli.includePositions, "positions", false, "Include entities (players, grenades...) positions (default false)") 55 | fs.BoolVar(&cli.minifyJSON, "minify", false, "Minify JSON file, it has effect only when -format is set to json") 56 | 57 | if err := fs.Parse(args); err != nil { 58 | return err 59 | } 60 | 61 | if err := cli.validateArgs(); err != nil { 62 | fmt.Fprintf(os.Stderr, "%v\n", err) 63 | fs.Usage() 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func Run(args []string) int { 71 | var cli cliArgs 72 | err := cli.fromArgs(args) 73 | if err != nil { 74 | return 2 75 | } 76 | 77 | err = api.AnalyzeAndExportDemo(cli.demoPath, cli.outputPath, api.AnalyzeAndExportDemoOptions{ 78 | IncludePositions: cli.includePositions, 79 | Source: constants.DemoSource(cli.source), 80 | Format: constants.ExportFormat(cli.format), 81 | MinifyJSON: cli.minifyJSON, 82 | }) 83 | 84 | if err != nil { 85 | fmt.Fprintf(os.Stderr, "%v\n", err) 86 | return 1 87 | } 88 | 89 | return 0 90 | } 91 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Options} */ 2 | module.exports = { 3 | singleQuote: true, 4 | printWidth: 120, 5 | }; 6 | -------------------------------------------------------------------------------- /tests/assertion/assert_clutches.go: -------------------------------------------------------------------------------- 1 | package assertion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | ) 8 | 9 | func AssertClutches(t *testing.T, match *api.Match, clutches []api.Clutch) { 10 | if len(match.Clutches) != len(clutches) { 11 | t.Errorf("Expected %d clutches, but got %d", len(clutches), len(match.Clutches)) 12 | } 13 | 14 | for index, expectedClutch := range clutches { 15 | clutch := match.Clutches[index] 16 | if clutch.RoundNumber != expectedClutch.RoundNumber { 17 | t.Errorf("Expected clutch round number to be %d, got %d", expectedClutch.RoundNumber, clutch.RoundNumber) 18 | } 19 | if clutch.ClutcherSteamID64 != expectedClutch.ClutcherSteamID64 { 20 | t.Errorf("Expected clutcher steam id to be %d, got %d for round %d", expectedClutch.ClutcherSteamID64, clutch.ClutcherSteamID64, expectedClutch.RoundNumber) 21 | } 22 | if clutch.ClutcherName != expectedClutch.ClutcherName { 23 | t.Errorf("Expected clutcher name to be %s, got %s for round %d", expectedClutch.ClutcherName, clutch.ClutcherName, expectedClutch.RoundNumber) 24 | } 25 | if clutch.HasWon != expectedClutch.HasWon { 26 | t.Errorf("Expected clutch has won to be %t, got %t for round %d", expectedClutch.HasWon, clutch.HasWon, expectedClutch.RoundNumber) 27 | } 28 | if clutch.ClutcherSurvived != expectedClutch.ClutcherSurvived { 29 | t.Errorf("Expected clutcher survived to be %t, got %t for round %d", expectedClutch.ClutcherSurvived, clutch.ClutcherSurvived, expectedClutch.RoundNumber) 30 | } 31 | if clutch.ClutcherKillCount != expectedClutch.ClutcherKillCount { 32 | t.Errorf("Expected clutcher kill count to be %d, got %d for round %d", expectedClutch.ClutcherKillCount, clutch.ClutcherKillCount, expectedClutch.RoundNumber) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/assertion/assert_player_economies.go: -------------------------------------------------------------------------------- 1 | package assertion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | ) 8 | 9 | func AssertPlayerEconomies(t *testing.T, match *api.Match, economies []api.PlayerEconomy) { 10 | for _, expectedEconomy := range economies { 11 | economy := match.GetPlayerEconomyAtRound(expectedEconomy.Name, expectedEconomy.SteamID64, expectedEconomy.RoundNumber) 12 | if economy == nil { 13 | t.Fail() 14 | t.Logf("could not find economy for player %s at round %d", expectedEconomy.Name, expectedEconomy.RoundNumber) 15 | break 16 | } 17 | 18 | if economy.RoundNumber != expectedEconomy.RoundNumber { 19 | t.Errorf("expected player economy round number to be %d but got %d", expectedEconomy.RoundNumber, economy.RoundNumber) 20 | } 21 | if economy.SteamID64 != expectedEconomy.SteamID64 { 22 | t.Errorf("expected SteamID for %s to be %d but got %d", expectedEconomy.Name, expectedEconomy.SteamID64, economy.SteamID64) 23 | } 24 | if economy.Name != expectedEconomy.Name { 25 | t.Errorf("expected player name to be %s but got %s", expectedEconomy.Name, economy.Name) 26 | } 27 | if economy.Type != expectedEconomy.Type { 28 | t.Errorf("expected player economy type to be %s but got %s for player %s at round %d", expectedEconomy.Type, economy.Type, economy.Name, expectedEconomy.RoundNumber) 29 | } 30 | if economy.StartMoney != expectedEconomy.StartMoney { 31 | t.Errorf("expected start money to be %d but got %d for player %s at round %d", expectedEconomy.StartMoney, economy.StartMoney, economy.Name, expectedEconomy.RoundNumber) 32 | } 33 | if economy.MoneySpent != expectedEconomy.MoneySpent { 34 | t.Errorf("expected money spent to be %d but got %d for player %s at round %d", expectedEconomy.MoneySpent, economy.MoneySpent, economy.Name, expectedEconomy.RoundNumber) 35 | } 36 | if economy.EquipmentValue != expectedEconomy.EquipmentValue { 37 | t.Errorf("expected equipment value to be %d but got %d for player %s at round %d", expectedEconomy.EquipmentValue, economy.EquipmentValue, economy.Name, expectedEconomy.RoundNumber) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/assertion/assert_players.go: -------------------------------------------------------------------------------- 1 | package assertion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | "github.com/akiver/cs-demo-analyzer/tests/fake" 8 | ) 9 | 10 | func AssertPlayers(t *testing.T, match *api.Match, players []fake.FakePlayer) { 11 | for _, expectedPlayer := range players { 12 | player := match.PlayersBySteamID[expectedPlayer.SteamID64] 13 | if player == nil { 14 | t.Errorf("expected player %s with SteamID %d to be found but got nil", expectedPlayer.Name, expectedPlayer.SteamID64) 15 | continue 16 | } 17 | if player.Name != expectedPlayer.Name { 18 | t.Errorf("expected player name to be %s but got %s for SteamID %d", expectedPlayer.Name, player.Name, player.SteamID64) 19 | } 20 | if player.KillCount() != expectedPlayer.KillCount { 21 | t.Errorf("expected player %s kill count to be %d but got %d for player %s", player.Name, expectedPlayer.KillCount, player.KillCount(), player.Name) 22 | } 23 | if player.AssistCount() != expectedPlayer.AssistCount { 24 | t.Errorf("expected player %s assist count to be %d but got %d for player %s", player.Name, expectedPlayer.AssistCount, player.AssistCount(), player.Name) 25 | } 26 | if player.DeathCount() != expectedPlayer.DeathCount { 27 | t.Errorf("expected player %s death count to be %d but got %d for player %s", player.Name, expectedPlayer.DeathCount, player.DeathCount(), player.Name) 28 | } 29 | if player.Score != expectedPlayer.Score { 30 | t.Errorf("expected player %s score to be %d but got %d for player %s", player.Name, expectedPlayer.Score, player.Score, player.Name) 31 | } 32 | if player.Team != expectedPlayer.Team { 33 | t.Errorf("expected player %s team to be %s but got %s for player %s", player.Name, expectedPlayer.Team.Letter, player.Team.Letter, player.Name) 34 | } 35 | if player.MvpCount != expectedPlayer.MvpCount { 36 | t.Errorf("expected player MVP to be %d but got %d for player %s", expectedPlayer.MvpCount, player.MvpCount, player.Name) 37 | } 38 | if player.HeadshotCount() != expectedPlayer.HeadshotCount { 39 | t.Errorf("expected player headshot count to be %d but got %d for player %s", expectedPlayer.HeadshotCount, player.HeadshotCount(), player.Name) 40 | } 41 | if player.UtilityDamage() != expectedPlayer.UtilityDamage { 42 | t.Errorf("expected player %s utility damage to be %d but got %d for player %s", player.Name, expectedPlayer.UtilityDamage, player.UtilityDamage(), player.Name) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/assertion/assert_rounds.go: -------------------------------------------------------------------------------- 1 | package assertion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/internal/slice" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api" 8 | "github.com/akiver/cs-demo-analyzer/tests/fake" 9 | ) 10 | 11 | func AssertRounds(t *testing.T, match *api.Match, rounds []fake.FakeRound) { 12 | for _, expectedRound := range rounds { 13 | round, found := slice.Find(match.Rounds, func(round *api.Round) bool { 14 | return round.Number == expectedRound.Number 15 | }) 16 | 17 | if !found { 18 | t.Errorf("round %d not found", expectedRound.Number) 19 | continue 20 | } 21 | 22 | if round.Number != expectedRound.Number { 23 | t.Errorf("expected round number %d but got %d", expectedRound.Number, round.Number) 24 | } 25 | if round.OvertimeNumber != expectedRound.OvertimeNumber { 26 | t.Errorf("expected round overtime number to be %d but got %d", expectedRound.OvertimeNumber, round.OvertimeNumber) 27 | } 28 | if round.StartTick != expectedRound.StartTick { 29 | t.Errorf("expected round start tick to be %d but got %d round number %d", expectedRound.StartTick, round.StartTick, round.Number) 30 | } 31 | if round.StartFrame != expectedRound.StartFrame { 32 | t.Errorf("expected round start frame to be %d but got %d round number %d", expectedRound.StartFrame, round.StartFrame, round.Number) 33 | } 34 | if round.FreezeTimeEndTick != expectedRound.FreezeTimeEndTick { 35 | t.Errorf("expected round freeze time end tick to be %d but got %d round number %d", expectedRound.FreezeTimeEndTick, round.FreezeTimeEndTick, round.Number) 36 | } 37 | if round.EndTick != expectedRound.EndTick { 38 | t.Errorf("expected round end tick to be %d but got %d round number %d", expectedRound.EndTick, round.EndTick, round.Number) 39 | } 40 | if expectedRound.EndOfficiallyTick != 0 && round.EndOfficiallyTick != expectedRound.EndOfficiallyTick { 41 | t.Errorf("expected round end officially tick to be %d but got %d round number %d", expectedRound.EndOfficiallyTick, round.EndOfficiallyTick, round.Number) 42 | } 43 | if expectedRound.FreezeTimeEndFrame != 0 && round.FreezeTimeEndFrame != expectedRound.FreezeTimeEndFrame { 44 | t.Errorf("expected round freeze time end frame to be %d but got %d round number %d", expectedRound.FreezeTimeEndFrame, round.FreezeTimeEndFrame, round.Number) 45 | } 46 | if expectedRound.EndFrame != 0 && round.EndFrame != expectedRound.EndFrame { 47 | t.Errorf("expected round end frame to be %d but got %d round number %d", expectedRound.EndFrame, round.EndFrame, round.Number) 48 | } 49 | if expectedRound.EndOfficiallyFrame != 0 && round.EndOfficiallyFrame != expectedRound.EndOfficiallyFrame { 50 | t.Errorf("expected round end officially frame to be %d but got %d round number %d", expectedRound.EndOfficiallyFrame, round.EndOfficiallyFrame, round.Number) 51 | } 52 | if expectedRound.TeamAStartMoney != 0 && round.StartMoneyTeamA() != expectedRound.TeamAStartMoney { 53 | t.Errorf("expected round start money team A to be %d but got %d round number %d", expectedRound.TeamAStartMoney, round.StartMoneyTeamA(), round.Number) 54 | } 55 | if expectedRound.TeamBStartMoney != 0 && round.StartMoneyTeamB() != expectedRound.TeamBStartMoney { 56 | t.Errorf("expected round start money team B to be %d but got %d round number %d", expectedRound.TeamBStartMoney, round.StartMoneyTeamB(), round.Number) 57 | } 58 | if expectedRound.TeamAEquipmentValue != 0 && round.TeamAEquipmentValue != expectedRound.TeamAEquipmentValue { 59 | t.Errorf("expected round equipment value team A to be %d but got %d round number %d", expectedRound.TeamAEquipmentValue, round.TeamAEquipmentValue, round.Number) 60 | } 61 | if expectedRound.TeamBEquipmentValue != 0 && round.TeamBEquipmentValue != expectedRound.TeamBEquipmentValue { 62 | t.Errorf("expected round equipment value team B to be %d but got %d round number %d", expectedRound.TeamBEquipmentValue, round.TeamBEquipmentValue, round.Number) 63 | } 64 | if expectedRound.TeamAEconomyType != "" && round.TeamAEconomyType != expectedRound.TeamAEconomyType { 65 | t.Errorf("expected round economy type team A (%s) to be %s but got %s round number %d", round.TeamAName, expectedRound.TeamAEconomyType, round.TeamAEconomyType, round.Number) 66 | } 67 | if expectedRound.TeamBEconomyType != "" && round.TeamBEconomyType != expectedRound.TeamBEconomyType { 68 | t.Errorf("expected round economy type team B (%s) to be %s but got %s round number %d", round.TeamBName, expectedRound.TeamBEconomyType, round.TeamBEconomyType, round.Number) 69 | } 70 | if expectedRound.EndReason != 0 && round.EndReason != expectedRound.EndReason { 71 | t.Errorf("expected round end reason to be %d but got %d round number %d", expectedRound.EndReason, round.EndReason, round.Number) 72 | } 73 | if expectedRound.WinnerSide != 0 && round.WinnerSide != expectedRound.WinnerSide { 74 | t.Errorf("expected round winner side to be %d but got %d round number %d", expectedRound.WinnerSide, round.WinnerSide, round.Number) 75 | } 76 | if expectedRound.WinnerName != "" && round.WinnerName != expectedRound.WinnerName { 77 | t.Errorf("expected round winner name to be %s but got %s round number %d", expectedRound.WinnerName, round.WinnerName, round.Number) 78 | } 79 | if round.TeamAScore != expectedRound.TeamAScore { 80 | t.Errorf("expected round score team A to be %d but got %d round number %d", expectedRound.TeamAScore, round.TeamAScore, round.Number) 81 | } 82 | if round.TeamBScore != expectedRound.TeamBScore { 83 | t.Errorf("expected round score team B to be %d but got %d round number %d", expectedRound.TeamBScore, round.TeamBScore, round.Number) 84 | } 85 | if expectedRound.TeamASide != 0 && round.TeamASide != expectedRound.TeamASide { 86 | t.Errorf("expected round side team A to be %d but got %d round number %d", expectedRound.TeamASide, round.TeamASide, round.Number) 87 | } 88 | if expectedRound.TeamBSide != 0 && round.TeamBSide != expectedRound.TeamBSide { 89 | t.Errorf("expected round side team B to be %d but got %d round number %d", expectedRound.TeamBSide, round.TeamBSide, round.Number) 90 | } 91 | if expectedRound.TeamAName != "" && expectedRound.TeamAName != round.TeamAName { 92 | t.Errorf("expected team A name to be %s but got %s round number %d", expectedRound.TeamAName, round.TeamAName, round.Number) 93 | } 94 | if expectedRound.TeamBName != "" && expectedRound.TeamBName != round.TeamBName { 95 | t.Errorf("expected team B name to be %s but got %s round number %d", expectedRound.TeamBName, round.TeamBName, round.Number) 96 | } 97 | } 98 | } 99 | 100 | func AssertKillCountAtRound(t *testing.T, match *api.Match, roundNumber int, killCount int) { 101 | roundKillCount := len(match.KillsByRound()[roundNumber]) 102 | if roundKillCount != killCount { 103 | t.Errorf("expected %d kills round %d but got %d", killCount, roundNumber, roundKillCount) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/cs2_matchzy_aurora_vs_3dmax_m3_2024_anubis_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/akiver/cs-demo-analyzer/tests/assertion" 9 | "github.com/akiver/cs-demo-analyzer/tests/fake" 10 | "github.com/akiver/cs-demo-analyzer/tests/testsutils" 11 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 12 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 13 | ) 14 | 15 | // https://www.hltv.org/stats/matches/mapstatsid/179910/aurora-vs-3dmax 16 | // - Contains a round backup restore at round 15 17 | // - Teams stay after knife round 18 | func Test_MatchZy_Aurora_vs_3dmax_m3_2024_Anubis(t *testing.T) { 19 | demoName := "matchzy_aurora_vs_3dmax_m3_anubis" 20 | demoPath := testsutils.GetDemoPath("cs2", demoName) 21 | match, err := api.AnalyzeDemo(demoPath, api.AnalyzeDemoOptions{ 22 | Source: constants.DemoSourceMatchZy, 23 | }) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | 28 | expectedRoundCount := 24 29 | expectedPlayerCount := 10 30 | expectedScoreTeamA := 11 31 | expectedScoreTeamB := 13 32 | expectedScoreFirstHalfTeamA := 6 33 | expectedScoreFirstHalfTeamB := 6 34 | expectedScoreSecondHalfTeamA := 5 35 | expectedScoreSecondHalfTeamB := 7 36 | expectedTeamNameA := "Aurora" 37 | expectedTeamNameB := "3DMAX" 38 | expectedWinnerName := expectedTeamNameB 39 | expectedMaxRounds := 24 40 | 41 | if len(match.Rounds) != expectedRoundCount { 42 | t.Errorf("expected %d rounds but got %d", expectedRoundCount, len(match.Rounds)) 43 | } 44 | if len(match.Players()) != expectedPlayerCount { 45 | t.Errorf("expected %d players but got %d", expectedPlayerCount, len(match.Players())) 46 | } 47 | if match.TeamA.Score != expectedScoreTeamA { 48 | t.Errorf("expected score team A to be %d got %d", expectedScoreTeamA, match.TeamA.Score) 49 | } 50 | if match.TeamB.Score != expectedScoreTeamB { 51 | t.Errorf("expected score team B to be %d got %d", expectedScoreTeamB, match.TeamB.Score) 52 | } 53 | if match.TeamA.Name != expectedTeamNameA { 54 | t.Errorf("expected team name A to be %s got %s", expectedTeamNameA, match.TeamA.Name) 55 | } 56 | if match.TeamB.Name != expectedTeamNameB { 57 | t.Errorf("expected team name B to be %s got %s", expectedTeamNameB, match.TeamB.Name) 58 | } 59 | if match.TeamA.ScoreFirstHalf != expectedScoreFirstHalfTeamA { 60 | t.Errorf("expected score first half team A to be %d got %d", expectedScoreFirstHalfTeamA, match.TeamA.ScoreFirstHalf) 61 | } 62 | if match.TeamB.ScoreFirstHalf != expectedScoreFirstHalfTeamB { 63 | t.Errorf("expected score first half team B to be %d got %d", expectedScoreFirstHalfTeamB, match.TeamB.ScoreFirstHalf) 64 | } 65 | if match.TeamA.ScoreSecondHalf != expectedScoreSecondHalfTeamA { 66 | t.Errorf("expected score second half team A to be %d got %d", expectedScoreSecondHalfTeamA, match.TeamA.ScoreSecondHalf) 67 | } 68 | if match.TeamB.ScoreSecondHalf != expectedScoreSecondHalfTeamB { 69 | t.Errorf("expected score second half team B to be %d got %d", expectedScoreSecondHalfTeamB, match.TeamB.ScoreSecondHalf) 70 | } 71 | if match.Winner.Name != expectedWinnerName { 72 | t.Errorf("expected winner to be %s but got %s", expectedWinnerName, match.Winner.Name) 73 | } 74 | if match.MaxRounds != expectedMaxRounds { 75 | t.Errorf("expected max rounds to be %d but got %d", expectedMaxRounds, match.MaxRounds) 76 | } 77 | 78 | var rounds = []fake.FakeRound{ 79 | { 80 | Number: 1, 81 | StartTick: 5109, 82 | StartFrame: 6155, 83 | EndTick: 23865, 84 | FreezeTimeEndTick: 19425, 85 | TeamAStartMoney: 4000, 86 | TeamBStartMoney: 4000, 87 | TeamAEconomyType: constants.EconomyTypePistol, 88 | TeamBEconomyType: constants.EconomyTypePistol, 89 | EndReason: events.RoundEndReasonCTWin, 90 | WinnerSide: common.TeamCounterTerrorists, 91 | TeamAScore: 1, 92 | TeamBScore: 0, 93 | WinnerName: expectedTeamNameA, 94 | TeamASide: common.TeamCounterTerrorists, 95 | TeamBSide: common.TeamTerrorists, 96 | TeamAName: expectedTeamNameA, 97 | TeamBName: expectedTeamNameB, 98 | }, 99 | { 100 | Number: 14, 101 | StartTick: 151097, 102 | StartFrame: 186965, 103 | EndTick: 155102, 104 | FreezeTimeEndTick: 152377, 105 | TeamAStartMoney: 18300, 106 | TeamBStartMoney: 11150, 107 | TeamAEconomyType: constants.EconomyTypeSemi, 108 | TeamBEconomyType: constants.EconomyTypeEco, 109 | EndReason: events.RoundEndReasonTerroristsWin, 110 | WinnerSide: common.TeamTerrorists, 111 | TeamAScore: 8, 112 | TeamBScore: 6, 113 | WinnerName: expectedTeamNameA, 114 | TeamASide: common.TeamTerrorists, 115 | TeamBSide: common.TeamCounterTerrorists, 116 | TeamAName: expectedTeamNameA, 117 | TeamBName: expectedTeamNameB, 118 | }, 119 | { 120 | Number: 15, 121 | StartTick: 160717, // end of pause after backup restore 122 | StartFrame: 199370, 123 | EndTick: 168197, 124 | FreezeTimeEndTick: 161996, 125 | TeamAStartMoney: 20000, 126 | TeamBStartMoney: 23050, 127 | TeamAEconomyType: constants.EconomyTypeFull, 128 | TeamBEconomyType: constants.EconomyTypeFull, 129 | EndReason: events.RoundEndReasonTerroristsWin, 130 | WinnerSide: common.TeamTerrorists, 131 | TeamAScore: 9, 132 | TeamBScore: 6, 133 | WinnerName: expectedTeamNameA, 134 | TeamASide: common.TeamTerrorists, 135 | TeamBSide: common.TeamCounterTerrorists, 136 | TeamAName: expectedTeamNameA, 137 | TeamBName: expectedTeamNameB, 138 | }, 139 | { 140 | Number: 23, 141 | StartTick: 230999, 142 | StartFrame: 285576, 143 | EndTick: 236189, 144 | FreezeTimeEndTick: 232279, 145 | TeamAStartMoney: 18700, 146 | TeamBStartMoney: 20550, 147 | TeamAEconomyType: constants.EconomyTypeForceBuy, 148 | TeamBEconomyType: constants.EconomyTypeFull, 149 | EndReason: events.RoundEndReasonBombDefused, 150 | WinnerSide: common.TeamCounterTerrorists, 151 | TeamAScore: 11, 152 | TeamBScore: 12, 153 | WinnerName: expectedTeamNameB, 154 | TeamASide: common.TeamTerrorists, 155 | TeamBSide: common.TeamCounterTerrorists, 156 | TeamAName: expectedTeamNameA, 157 | TeamBName: expectedTeamNameB, 158 | }, 159 | { 160 | Number: 24, 161 | StartTick: 238429, 162 | StartFrame: 295448, 163 | EndTick: 245633, 164 | FreezeTimeEndTick: 239709, 165 | TeamAStartMoney: 20350, 166 | TeamBStartMoney: 21750, 167 | TeamAEconomyType: constants.EconomyTypeFull, 168 | TeamBEconomyType: constants.EconomyTypeFull, 169 | EndReason: events.RoundEndReasonCTWin, 170 | WinnerSide: common.TeamCounterTerrorists, 171 | TeamAScore: 11, 172 | TeamBScore: 13, 173 | WinnerName: expectedTeamNameB, 174 | TeamASide: common.TeamTerrorists, 175 | TeamBSide: common.TeamCounterTerrorists, 176 | TeamAName: expectedTeamNameA, 177 | TeamBName: expectedTeamNameB, 178 | }, 179 | } 180 | 181 | assertion.AssertKillCountAtRound(t, match, 15, 6) 182 | 183 | assertion.AssertRounds(t, match, rounds) 184 | } 185 | -------------------------------------------------------------------------------- /tests/csgo_ebot_astralis_vs_envyus_game_show_global_esports_cup_2016_cache_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/akiver/cs-demo-analyzer/tests/assertion" 9 | "github.com/akiver/cs-demo-analyzer/tests/fake" 10 | "github.com/akiver/cs-demo-analyzer/tests/testsutils" 11 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 12 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 13 | ) 14 | 15 | // https://www.hltv.org/stats/matches/mapstatsid/40296/astralis-vs-envy 16 | func TestEbot_Astralis_VS_Envyus_Game_Show_Global_eSports_Cup_2016_Cache(t *testing.T) { 17 | demoName := "ebot_astralis_vs_envyus_game_show_global_esports_cup_2016_cache" 18 | demoPath := testsutils.GetDemoPath("csgo", demoName) 19 | match, err := api.AnalyzeDemo(demoPath, api.AnalyzeDemoOptions{ 20 | Source: constants.DemoSourceEbot, 21 | }) 22 | if err != nil { 23 | t.Error(err) 24 | } 25 | 26 | expectedRoundCount := 29 27 | expectedPlayerCount := 10 28 | expectedScoreTeamA := 13 29 | expectedScoreTeamB := 16 30 | expectedScoreFirstHalfTeamA := 9 31 | expectedScoreFirstHalfTeamB := 6 32 | expectedScoreSecondHalfTeamA := 4 33 | expectedScoreSecondHalfTeamB := 10 34 | expectedTeamNameA := "Astralis" 35 | expectedTeamNameB := "EnVyUs" 36 | expectedWinnerName := expectedTeamNameB 37 | expectedMaxRounds := 30 38 | 39 | if len(match.Rounds) != expectedRoundCount { 40 | t.Errorf("expected %d rounds but got %d", expectedRoundCount, len(match.Rounds)) 41 | } 42 | if len(match.Players()) != expectedPlayerCount { 43 | t.Errorf("expected %d players but got %d", expectedPlayerCount, len(match.Players())) 44 | } 45 | if match.TeamA.Score != expectedScoreTeamA { 46 | t.Errorf("expected score team A to be %d got %d", expectedScoreTeamA, match.TeamA.Score) 47 | } 48 | if match.TeamB.Score != expectedScoreTeamB { 49 | t.Errorf("expected score team B to be %d got %d", expectedScoreTeamB, match.TeamB.Score) 50 | } 51 | if match.TeamA.Name != expectedTeamNameA { 52 | t.Errorf("expected team name A to be %s got %s", expectedTeamNameA, match.TeamA.Name) 53 | } 54 | if match.TeamB.Name != expectedTeamNameB { 55 | t.Errorf("expected team name B to be %s got %s", expectedTeamNameB, match.TeamB.Name) 56 | } 57 | if match.TeamA.ScoreFirstHalf != expectedScoreFirstHalfTeamA { 58 | t.Errorf("expected score first half team A to be %d got %d", expectedScoreFirstHalfTeamA, match.TeamA.ScoreFirstHalf) 59 | } 60 | if match.TeamB.ScoreFirstHalf != expectedScoreFirstHalfTeamB { 61 | t.Errorf("expected score first half team B to be %d got %d", expectedScoreFirstHalfTeamB, match.TeamB.ScoreFirstHalf) 62 | } 63 | if match.TeamA.ScoreSecondHalf != expectedScoreSecondHalfTeamA { 64 | t.Errorf("expected score second half team A to be %d got %d", expectedScoreSecondHalfTeamA, match.TeamA.ScoreSecondHalf) 65 | } 66 | if match.TeamB.ScoreSecondHalf != expectedScoreSecondHalfTeamB { 67 | t.Errorf("expected score second half team B to be %d got %d", expectedScoreSecondHalfTeamB, match.TeamB.ScoreSecondHalf) 68 | } 69 | if match.Winner.Name != expectedWinnerName { 70 | t.Errorf("expected winner to be %s but got %s", expectedWinnerName, match.Winner.Name) 71 | } 72 | if match.MaxRounds != expectedMaxRounds { 73 | t.Errorf("expected max rounds to be %d but got %d", expectedMaxRounds, match.MaxRounds) 74 | } 75 | 76 | var rounds = []fake.FakeRound{ 77 | { 78 | Number: 1, 79 | StartTick: 0, 80 | StartFrame: 6, 81 | EndTick: 11929, 82 | FreezeTimeEndTick: 0, 83 | TeamAStartMoney: 4000, 84 | TeamBStartMoney: 4000, 85 | TeamAEconomyType: constants.EconomyTypePistol, 86 | TeamBEconomyType: constants.EconomyTypePistol, 87 | EndReason: events.RoundEndReasonBombDefused, 88 | WinnerSide: common.TeamCounterTerrorists, 89 | TeamAScore: 1, 90 | TeamBScore: 0, 91 | WinnerName: expectedTeamNameA, 92 | TeamASide: common.TeamCounterTerrorists, 93 | TeamBSide: common.TeamTerrorists, 94 | TeamAName: expectedTeamNameA, 95 | TeamBName: expectedTeamNameB, 96 | }, 97 | } 98 | 99 | var players = []fake.FakePlayer{ 100 | { 101 | SteamID64: 76561197978241352, 102 | Name: "ENVYUS HappyV", 103 | KillCount: 23, 104 | AssistCount: 2, 105 | DeathCount: 16, 106 | Score: 51, 107 | Team: match.TeamB, 108 | HeadshotCount: 10, 109 | MvpCount: 1, 110 | UtilityDamage: 32, 111 | }, 112 | { 113 | SteamID64: 76561197990682262, 114 | Name: "Xyp9x", 115 | KillCount: 10, 116 | AssistCount: 8, 117 | DeathCount: 25, 118 | Score: 32, 119 | Team: match.TeamA, 120 | HeadshotCount: 3, 121 | MvpCount: 3, 122 | UtilityDamage: 146, 123 | }, 124 | { 125 | SteamID64: 76561198024905796, 126 | Name: "ENVYUS KENNYS -M-", 127 | KillCount: 30, 128 | AssistCount: 4, 129 | DeathCount: 17, 130 | Score: 75, 131 | Team: match.TeamB, 132 | HeadshotCount: 6, 133 | MvpCount: 4, 134 | UtilityDamage: 42, 135 | }, 136 | { 137 | SteamID64: 76561197960710573, 138 | Name: "ENVYUS NBK-", 139 | KillCount: 19, 140 | AssistCount: 4, 141 | DeathCount: 19, 142 | Score: 52, 143 | Team: match.TeamB, 144 | HeadshotCount: 10, 145 | MvpCount: 3, 146 | UtilityDamage: 20, 147 | }, 148 | { 149 | SteamID64: 76561197989744167, 150 | Name: "ENVYUS apEXmousse[D]", 151 | KillCount: 20, 152 | AssistCount: 2, 153 | DeathCount: 20, 154 | Score: 48, 155 | Team: match.TeamB, 156 | HeadshotCount: 9, 157 | MvpCount: 3, 158 | UtilityDamage: 105, 159 | }, 160 | { 161 | SteamID64: 76561197987713664, 162 | Name: "dEV1CEE -M-", 163 | KillCount: 12, 164 | AssistCount: 5, 165 | DeathCount: 21, 166 | Score: 32, 167 | Team: match.TeamA, 168 | HeadshotCount: 5, 169 | MvpCount: 1, 170 | UtilityDamage: 52, 171 | }, 172 | { 173 | SteamID64: 76561197989430253, 174 | Name: "kARR1GANN", 175 | KillCount: 23, 176 | AssistCount: 3, 177 | DeathCount: 24, 178 | Score: 56, 179 | Team: match.TeamA, 180 | HeadshotCount: 11, 181 | MvpCount: 3, 182 | UtilityDamage: 36, 183 | }, 184 | { 185 | SteamID64: 76561198000782895, 186 | Name: "ENVYUS k10$|-|1m@[C]", 187 | KillCount: 17, 188 | AssistCount: 3, 189 | DeathCount: 21, 190 | Score: 49, 191 | Team: match.TeamB, 192 | HeadshotCount: 6, 193 | MvpCount: 5, 194 | UtilityDamage: 349, 195 | }, 196 | { 197 | SteamID64: 76561197996352604, 198 | Name: "cajunb", 199 | KillCount: 27, 200 | AssistCount: 3, 201 | DeathCount: 21, 202 | Score: 63, 203 | Team: match.TeamA, 204 | HeadshotCount: 9, 205 | MvpCount: 2, 206 | UtilityDamage: 79, 207 | }, 208 | { 209 | SteamID64: 76561198004854956, 210 | Name: "dupreeh <3 OhLongJohnson", 211 | KillCount: 21, 212 | AssistCount: 5, 213 | DeathCount: 18, 214 | Score: 56, 215 | Team: match.TeamA, 216 | HeadshotCount: 10, 217 | MvpCount: 4, 218 | UtilityDamage: 60, 219 | }, 220 | } 221 | 222 | assertion.AssertPlayers(t, match, players) 223 | assertion.AssertRounds(t, match, rounds) 224 | } 225 | -------------------------------------------------------------------------------- /tests/csgo_ebot_galatics_vs_nerdrage_alientech_csgo_league_season1_2016_cache_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/akiver/cs-demo-analyzer/tests/assertion" 9 | "github.com/akiver/cs-demo-analyzer/tests/fake" 10 | "github.com/akiver/cs-demo-analyzer/tests/testsutils" 11 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 12 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 13 | ) 14 | 15 | // Knife round with teams switch. 16 | // The game is stopped a few seconds after the beginning of the 1st round. 17 | // https://www.hltv.org/stats/matches/mapstatsid/28613/nerdrage-vs-galatics 18 | func TestEbot_Galatics_VS_Nerdrage_AlienTech_CSGO_League_Season1_2016_Cache(t *testing.T) { 19 | demoName := "ebot_galatics_vs_nerdrage_alientech_csgo_league_season1_2016_cache" 20 | demoPath := testsutils.GetDemoPath("csgo", demoName) 21 | match, err := api.AnalyzeDemo(demoPath, api.AnalyzeDemoOptions{ 22 | Source: constants.DemoSourceEbot, 23 | }) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | 28 | expectedRoundCount := 20 29 | expectedPlayerCount := 10 30 | expectedScoreTeamA := 16 31 | expectedScoreTeamB := 4 32 | expectedScoreFirstHalfTeamA := 13 33 | expectedScoreFirstHalfTeamB := 2 34 | expectedScoreSecondHalfTeamA := 3 35 | expectedScoreSecondHalfTeamB := 2 36 | expectedTeamNameA := "NerdRage" 37 | expectedTeamNameB := "GALATICS.CSGO" 38 | expectedWinnerName := expectedTeamNameA 39 | 40 | if len(match.Rounds) != expectedRoundCount { 41 | t.Errorf("expected %d rounds but got %d", expectedRoundCount, len(match.Rounds)) 42 | } 43 | if len(match.Players()) != expectedPlayerCount { 44 | t.Errorf("expected %d players but got %d", expectedPlayerCount, len(match.Players())) 45 | } 46 | if match.TeamA.Score != expectedScoreTeamA { 47 | t.Errorf("expected score team A to be %d got %d", expectedScoreTeamA, match.TeamA.Score) 48 | } 49 | if match.TeamB.Score != expectedScoreTeamB { 50 | t.Errorf("expected score team B to be %d got %d", expectedScoreTeamB, match.TeamB.Score) 51 | } 52 | if match.TeamA.Name != expectedTeamNameA { 53 | t.Errorf("expected team name A to be %s got %s", expectedTeamNameA, match.TeamA.Name) 54 | } 55 | if match.TeamB.Name != expectedTeamNameB { 56 | t.Errorf("expected team name B to be %s got %s", expectedTeamNameB, match.TeamB.Name) 57 | } 58 | if match.TeamA.ScoreFirstHalf != expectedScoreFirstHalfTeamA { 59 | t.Errorf("expected score first half team A to be %d got %d", expectedScoreFirstHalfTeamA, match.TeamA.ScoreFirstHalf) 60 | } 61 | if match.TeamB.ScoreFirstHalf != expectedScoreFirstHalfTeamB { 62 | t.Errorf("expected score first half team B to be %d got %d", expectedScoreFirstHalfTeamB, match.TeamB.ScoreFirstHalf) 63 | } 64 | if match.TeamA.ScoreSecondHalf != expectedScoreSecondHalfTeamA { 65 | t.Errorf("expected score second half team A to be %d got %d", expectedScoreSecondHalfTeamA, match.TeamA.ScoreSecondHalf) 66 | } 67 | if match.TeamB.ScoreSecondHalf != expectedScoreSecondHalfTeamB { 68 | t.Errorf("expected score second half team B to be %d got %d", expectedScoreSecondHalfTeamB, match.TeamB.ScoreSecondHalf) 69 | } 70 | if match.Winner != nil && match.Winner.Name != expectedWinnerName { 71 | t.Errorf("expected winner to be %s but got %s", expectedWinnerName, match.Winner.Name) 72 | } 73 | 74 | var rounds = []fake.FakeRound{ 75 | { 76 | Number: 1, 77 | StartTick: 29831, 78 | StartFrame: 3734, 79 | EndTick: 40145, 80 | FreezeTimeEndTick: 31367, 81 | EndOfficiallyTick: 40789, 82 | TeamAStartMoney: 4000, 83 | TeamBStartMoney: 4000, 84 | TeamAEconomyType: constants.EconomyTypePistol, 85 | TeamBEconomyType: constants.EconomyTypePistol, 86 | EndReason: events.RoundEndReasonTargetBombed, 87 | WinnerSide: common.TeamTerrorists, 88 | TeamAScore: 0, 89 | TeamBScore: 1, 90 | WinnerName: expectedTeamNameB, 91 | TeamASide: common.TeamCounterTerrorists, 92 | TeamBSide: common.TeamTerrorists, 93 | TeamAName: expectedTeamNameA, 94 | TeamBName: expectedTeamNameB, 95 | }, 96 | { 97 | Number: 2, 98 | StartTick: 40789, 99 | StartFrame: 5103, 100 | EndTick: 47629, 101 | FreezeTimeEndTick: 42325, 102 | EndOfficiallyTick: 48269, 103 | TeamAStartMoney: 9100, 104 | TeamBStartMoney: 15750, 105 | TeamAEconomyType: constants.EconomyTypeForceBuy, 106 | TeamBEconomyType: constants.EconomyTypeSemi, 107 | EndReason: events.RoundEndReasonCTWin, 108 | WinnerSide: common.TeamCounterTerrorists, 109 | TeamAScore: 1, 110 | TeamBScore: 1, 111 | WinnerName: expectedTeamNameA, 112 | TeamASide: common.TeamCounterTerrorists, 113 | TeamBSide: common.TeamTerrorists, 114 | TeamAName: expectedTeamNameA, 115 | TeamBName: expectedTeamNameB, 116 | }, 117 | } 118 | 119 | assertion.AssertRounds(t, match, rounds) 120 | } 121 | -------------------------------------------------------------------------------- /tests/csgo_valve_match730_003598554255364980910_1802085029_272_ancient_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/akiver/cs-demo-analyzer/pkg/api" 7 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 8 | "github.com/akiver/cs-demo-analyzer/tests/testsutils" 9 | ) 10 | 11 | // Valve short match (MR 8) demo. 12 | func TestValve_Match730_003598554255364980910_1802085029_272_Ancient(t *testing.T) { 13 | demoName := "valve_match730_003598554255364980910_1802085029_272_ancient" 14 | demoPath := testsutils.GetDemoPath("csgo", demoName) 15 | match, err := api.AnalyzeDemo(demoPath, api.AnalyzeDemoOptions{ 16 | Source: constants.DemoSourceValve, 17 | }) 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | expectedRoundCount := 14 22 | expectedPlayerCount := 10 23 | expectedTeamAScore := 5 24 | expectedTeamBScore := 9 25 | expectedScoreFirstHalfTeamA := 2 26 | expectedScoreFirstHalfTeamB := 6 27 | expectedScoreSecondHalfTeamA := 3 28 | expectedScoreSecondHalfTeamB := 3 29 | expectedTeamNameA := "Team A" 30 | expectedTeamNameB := "Team B" 31 | expectedWinnerName := expectedTeamNameB 32 | expectedMaxRounds := 16 33 | 34 | if match.TeamA.Name != expectedTeamNameA { 35 | t.Errorf("expected team name A to be %s got %s", expectedTeamNameA, match.TeamA.Name) 36 | } 37 | if match.TeamB.Name != expectedTeamNameB { 38 | t.Errorf("expected team name B to be %s got %s", expectedTeamNameB, match.TeamB.Name) 39 | } 40 | if len(match.Rounds) != expectedRoundCount { 41 | t.Errorf("expected %d rounds but got %d", expectedRoundCount, len(match.Rounds)) 42 | } 43 | if len(match.Players()) != expectedPlayerCount { 44 | t.Errorf("expected %d players but got %d", expectedPlayerCount, len(match.Players())) 45 | } 46 | if match.TeamA.Score != expectedTeamAScore { 47 | t.Errorf("expected teamScTeamBScore A to be %d got %d", expectedTeamAScore, match.TeamA.Score) 48 | } 49 | if match.TeamB.Score != expectedTeamBScore { 50 | t.Errorf("expected score team B to be %d got %d", expectedTeamBScore, match.TeamB.Score) 51 | } 52 | if match.TeamA.ScoreFirstHalf != expectedScoreFirstHalfTeamA { 53 | t.Errorf("expected score first half team A to be %d got %d", expectedScoreFirstHalfTeamA, match.TeamA.ScoreFirstHalf) 54 | } 55 | if match.TeamB.ScoreFirstHalf != expectedScoreFirstHalfTeamB { 56 | t.Errorf("expected score first half team B to be %d got %d", expectedScoreFirstHalfTeamB, match.TeamB.ScoreFirstHalf) 57 | } 58 | if match.TeamA.ScoreSecondHalf != expectedScoreSecondHalfTeamA { 59 | t.Errorf("expected score second half team A to be %d got %d", expectedScoreSecondHalfTeamA, match.TeamA.ScoreSecondHalf) 60 | } 61 | if match.TeamB.ScoreSecondHalf != expectedScoreSecondHalfTeamB { 62 | t.Errorf("expected score second half team B to be %d got %d", expectedScoreSecondHalfTeamB, match.TeamB.ScoreSecondHalf) 63 | } 64 | if match.Winner.Name != expectedWinnerName { 65 | t.Errorf("expected winner to be %s but got %s", expectedWinnerName, match.Winner.Name) 66 | } 67 | if match.MaxRounds != expectedMaxRounds { 68 | t.Errorf("expected max rounds to be %d but got %d", expectedMaxRounds, match.MaxRounds) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/fake/fake_player.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import "github.com/akiver/cs-demo-analyzer/pkg/api" 4 | 5 | type FakePlayer struct { 6 | SteamID64 uint64 7 | Name string 8 | Score int 9 | Team *api.Team 10 | KillCount int 11 | AssistCount int 12 | DeathCount int 13 | MvpCount int 14 | HeadshotCount int 15 | UtilityDamage int 16 | } 17 | -------------------------------------------------------------------------------- /tests/fake/fake_round.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "github.com/akiver/cs-demo-analyzer/pkg/api/constants" 5 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common" 6 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events" 7 | ) 8 | 9 | type FakeRound struct { 10 | Number int 11 | StartTick int 12 | StartFrame int 13 | FreezeTimeEndTick int 14 | FreezeTimeEndFrame int 15 | EndTick int 16 | EndFrame int 17 | EndOfficiallyTick int 18 | EndOfficiallyFrame int 19 | OvertimeNumber int 20 | TeamAName string 21 | TeamBName string 22 | TeamASide common.Team 23 | TeamBSide common.Team 24 | TeamAScore int 25 | TeamBScore int 26 | TeamAStartMoney int 27 | TeamBStartMoney int 28 | TeamAEquipmentValue int 29 | TeamBEquipmentValue int 30 | TeamAEconomyType constants.EconomyType 31 | TeamBEconomyType constants.EconomyType 32 | EndReason events.RoundEndReason 33 | WinnerName string 34 | WinnerSide common.Team 35 | } 36 | -------------------------------------------------------------------------------- /tests/testsutils/get_demo_path.go: -------------------------------------------------------------------------------- 1 | package testsutils 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | // GetDemoPath returns the path to the demo for testing. 8 | func GetDemoPath(gameFolder string, name string) string { 9 | demosFolderPath := "../cs-demos/" + gameFolder + "/" 10 | return filepath.Join(demosFolderPath + name + ".dem") 11 | } 12 | --------------------------------------------------------------------------------