├── .github
├── dependabot.yml
└── workflows
│ ├── publish.yml
│ ├── testandbuild.yml
│ └── typedoc.yml
├── .gitignore
├── .prettierignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docs
└── algorithms.md
├── eslint.config.mjs
├── examples
├── highlevel.js
├── highlevel.ts
├── lowlevel.js
├── lowlevel.ts
└── output.json
├── package-lock.json
├── package.json
├── src
├── Player.ts
├── W3GReplay.ts
├── convert.ts
├── detectRetraining.ts
├── index.ts
├── inferHeroAbilityLevelsFromAbilityOrder.ts
├── mappings.ts
├── parsers
│ ├── ActionParser.ts
│ ├── GameDataParser.ts
│ ├── MetadataParser.ts
│ ├── RawParser.ts
│ ├── ReplayParser.ts
│ ├── StatefulBufferParser.ts
│ └── formatters.ts
├── sort.ts
└── types.ts
├── test
├── convert.test.ts
├── formatters.test.ts
├── getRetrainingIndex.test.ts
├── inferHeroAbilityLevelsFromAbilityOrder.test.ts
├── jest.config.js
├── replays
│ ├── 126
│ │ ├── 999.w3g
│ │ ├── replays.test.ts
│ │ └── standard_126.w3g
│ ├── 129
│ │ ├── netease_129_obs.nwg
│ │ ├── replays.test.ts
│ │ ├── standard_129_3on3_leaver.w3g
│ │ └── standard_129_obs.w3g
│ ├── 130
│ │ ├── __snapshots__
│ │ │ └── replays.test.ts.snap
│ │ ├── replays.test.ts
│ │ ├── standard_130.w3g
│ │ ├── standard_1302.w3g
│ │ ├── standard_1303.w3g
│ │ ├── standard_1304.2on2.w3g
│ │ └── standard_1304.w3g
│ ├── 131
│ │ ├── action0x7a.w3g
│ │ ├── replays.test.ts
│ │ ├── roc-losttemple-mapname.w3g
│ │ └── standard_tomeofretraining_1.w3g
│ ├── 132
│ │ ├── 1448202825.w3g
│ │ ├── 1582070968.nwg
│ │ ├── 1582161008.nwg
│ │ ├── 1640262494.w3g
│ │ ├── 706266088.w3g
│ │ ├── __snapshots__
│ │ │ └── replays.test.ts.snap
│ │ ├── benjiii_vs_Scars_Concealed_Hill.w3g
│ │ ├── buildingwin_anxietyperspective.w3g
│ │ ├── buildingwin_helpstoneperspective.w3g
│ │ ├── ced_vs_lyn.w3g
│ │ ├── esl_cup_vs_changer_1.w3g
│ │ ├── moju_vs_fly.nwg
│ │ ├── netease_132.nwg
│ │ ├── reforged1.w3g
│ │ ├── reforged2.w3g
│ │ ├── reforged2010.w3g
│ │ ├── reforged_hunter2_privatestring.w3g
│ │ ├── reforged_metadata_ghostplayer.w3g
│ │ ├── reforged_release.w3g
│ │ ├── reforged_truncated_playernames.w3g
│ │ ├── replay_fullobs.w3g
│ │ ├── replay_obs_on_defeat.w3g
│ │ ├── replay_randomhero_randomraces.w3g
│ │ ├── replay_referee.w3g
│ │ ├── replay_teamstogether.w3g
│ │ ├── replays.test.ts
│ │ ├── twistedmeadows.w3g
│ │ └── wan_vs_trunks.w3g
│ └── 200
│ │ ├── 2.0.2-FloTVSavedByWc3.w3g
│ │ ├── 2.0.2-LAN-bots.w3g
│ │ ├── 2.0.2-Melee.w3g
│ │ ├── TempReplay.w3g
│ │ ├── goldmine test.w3g
│ │ ├── replays.test.ts
│ │ └── retrainingissues.w3g
├── schema.json
└── sortfunctions.test.ts
└── tsconfig.json
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | groups:
8 | all-dependencies-but-not-node-types:
9 | applies-to: version-updates
10 | patterns:
11 | - "*"
12 | exclude-patterns:
13 | - "@types/node"
14 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 |
6 | jobs:
7 | publish:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-node@v4
12 | with:
13 | node-version: 18
14 | cache: "npm"
15 | - run: npm install
16 | - run: npm run lint
17 | - run: npm test
18 | - run: npm run build
19 | - uses: JS-DevTools/npm-publish@v3
20 | with:
21 | token: ${{ secrets.NPM_TOKEN }}
22 |
--------------------------------------------------------------------------------
/.github/workflows/testandbuild.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 | on: [push, pull_request]
3 | jobs:
4 | testandbuild:
5 | runs-on: ubuntu-latest
6 | strategy:
7 | matrix:
8 | node-version: [22.x, 21.x, 20.x, 19.x, 18.x]
9 | steps:
10 | - uses: actions/checkout@v4
11 | - name: Use Node.js ${{ matrix.node-version }}
12 | uses: actions/setup-node@v4
13 | with:
14 | node-version: ${{ matrix.node-version }}
15 | - run: npm install
16 | - run: npm run lint
17 | - run: npm run test:coverage
18 | - uses: qltysh/qlty-action/coverage@main
19 | with:
20 | coverage-token: ${{secrets.QLTY_COVERAGE_TOKEN}}
21 | files: coverage/lcov.info
22 | - run: npm run build
23 | env:
24 | CI: true
25 |
--------------------------------------------------------------------------------
/.github/workflows/typedoc.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 | jobs:
6 | make-docs:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: actions/setup-node@v4
11 | with:
12 | node-version: 16
13 | - run: npm i -g typescript typedoc
14 | - run: npm i
15 | - run: typedoc --out doc src
16 | - run: touch ./doc/.nojekyll
17 | - name: Deploy
18 | uses: JamesIves/github-pages-deploy-action@4.1.7
19 | with:
20 | branch: gh-pages
21 | folder: doc
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .npmignore
3 | replays_batch/
4 | yarn.lock
5 | .nyc_output
6 | coverage
7 | yarn-error.log
8 | dist
9 | .vscode
10 | .rpt2_cache
11 | playground.js
12 | doc
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | doc
4 | CHANGELOG.md
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ## [3.0.0](https://github.com/PBug90/w3gjs/compare/v3.0.0-0...v3.0.0) (2025-04-24)
6 |
7 |
8 | ### Features
9 |
10 | * support new blizzard action types ([#205](https://github.com/PBug90/w3gjs/issues/205)) ([e76bd73](https://github.com/PBug90/w3gjs/commit/e76bd731acde390dc53447e8936438f30e3daf84))
11 |
12 |
13 | ### Bug Fixes
14 |
15 | * **#206:** switch console.log to console.error for errors ([#207](https://github.com/PBug90/w3gjs/issues/207)) ([63ee5a1](https://github.com/PBug90/w3gjs/commit/63ee5a15ae9f8a24d209a5ef97fc68842408f131)), closes [#206](https://github.com/PBug90/w3gjs/issues/206)
16 |
17 | ## [3.0.0-0](https://github.com/PBug90/w3gjs/compare/v2.5.5...v3.0.0-0) (2025-04-20)
18 |
19 |
20 | ### ⚠ BREAKING CHANGES
21 |
22 | * **#197:** update existing actions payloads and parse new action types from reverse engineering 2.0.1
23 |
24 | ### Features
25 |
26 | * **#197:** update existing actions payloads and parse new action types from reverse engineering 2.0.1 ([370601c](https://github.com/PBug90/w3gjs/commit/370601c7f74274227aa259520d9d72f2c9ac41d6)), closes [#197](https://github.com/PBug90/w3gjs/issues/197)
27 |
28 | ### [2.5.5](https://github.com/PBug90/w3gjs/compare/v2.5.4...v2.5.5) (2025-04-17)
29 |
30 |
31 | ### Bug Fixes
32 |
33 | * **#191:** parse 0x78 as BlzSyncAction and provide buffers for unknown actions ([#193](https://github.com/PBug90/w3gjs/issues/193)) ([726ba72](https://github.com/PBug90/w3gjs/commit/726ba729a748f369f0ff8c318f07f699622c8256)), closes [#191](https://github.com/PBug90/w3gjs/issues/191)
34 |
35 | ### [2.5.4](https://github.com/PBug90/w3gjs/compare/v2.5.2...v2.5.4) (2025-04-16)
36 |
37 |
38 | ### Bug Fixes
39 |
40 | * **#191:** parse UI actions successfully ([#192](https://github.com/PBug90/w3gjs/issues/192)) ([c867bdf](https://github.com/PBug90/w3gjs/commit/c867bdf046dfea8d3101abd0d95b7d518b3dba29)), closes [#191](https://github.com/PBug90/w3gjs/issues/191)
41 |
42 | ### [2.5.3](https://github.com/PBug90/w3gjs/compare/v2.5.2...v2.5.3) (2025-04-16)
43 |
44 |
45 | ### Bug Fixes
46 |
47 | * **#191:** parse UI actions successfully ([#192](https://github.com/PBug90/w3gjs/issues/192)) ([c867bdf](https://github.com/PBug90/w3gjs/commit/c867bdf046dfea8d3101abd0d95b7d518b3dba29)), closes [#191](https://github.com/PBug90/w3gjs/issues/191)
48 |
49 | ### [2.5.2](https://github.com/PBug90/w3gjs/compare/v2.5.1...v2.5.2) (2025-02-05)
50 |
51 |
52 | ### Bug Fixes
53 |
54 | * **#163:** parse game version for >= 2.00 correctly ([#164](https://github.com/PBug90/w3gjs/issues/164)) ([511716a](https://github.com/PBug90/w3gjs/commit/511716a8788f22c5d1225ef63ed2c98c6a3cf71b)), closes [#163](https://github.com/PBug90/w3gjs/issues/163)
55 |
56 | ### [2.5.1](https://github.com/PBug90/w3gjs/compare/v2.5.0...v2.5.1) (2025-02-05)
57 |
58 |
59 | ### Bug Fixes
60 |
61 | * **#161:** track haunted gold mine build commands ([#162](https://github.com/PBug90/w3gjs/issues/162)) ([4f6400d](https://github.com/PBug90/w3gjs/commit/4f6400d99bfc4f88ac15b419ccb6e195128e2637)), closes [#161](https://github.com/PBug90/w3gjs/issues/161)
62 |
63 | ## [2.5.0](https://github.com/PBug90/w3gjs/compare/v2.4.3...v2.5.0) (2022-04-12)
64 |
65 |
66 | ### Features
67 |
68 | * basic winner detection for 1on1 games ([#98](https://github.com/PBug90/w3gjs/issues/98)) ([031bf52](https://github.com/PBug90/w3gjs/commit/031bf521d7a5d88382f1e42ac722237580c3f2ef))
69 |
70 | ### [2.4.3](https://github.com/PBug90/w3gjs/compare/v2.4.2...v2.4.3) (2021-12-10)
71 |
72 |
73 | ### Bug Fixes
74 |
75 | * better hero ability & level calculation ([#75](https://github.com/PBug90/w3gjs/issues/75)) ([0f79376](https://github.com/PBug90/w3gjs/commit/0f7937688fc161c2be9f4802589540f8f1c6565d))
76 |
77 | ### [2.4.2](https://github.com/PBug90/w3gjs/compare/v2.4.1...v2.4.2) (2021-05-18)
78 |
79 |
80 | ### Bug Fixes
81 |
82 | * less restrictive map file name extraction regex ([#87](https://github.com/PBug90/w3gjs/issues/87)) ([5731637](https://github.com/PBug90/w3gjs/commit/573163787bf361486b0827f8f19eab2cf9f8647d))
83 |
84 | ### [2.4.1](https://github.com/PBug90/w3gjs/compare/v2.4.0...v2.4.1) (2021-05-15)
85 |
86 |
87 | ### Bug Fixes
88 |
89 | * chat messages include correctly mapped chat mode ([#85](https://github.com/PBug90/w3gjs/issues/85)) ([23911e2](https://github.com/PBug90/w3gjs/commit/23911e28bd0229a441ce2ee19ffef5f8b124bcdc))
90 |
91 | ## [2.4.0](https://github.com/PBug90/w3gjs/compare/v2.3.0...v2.4.0) (2021-05-06)
92 |
93 |
94 | ### Features
95 |
96 | * include 0x1A actions in CommandBlocks ([#81](https://github.com/PBug90/w3gjs/issues/81)) ([27b6fb1](https://github.com/PBug90/w3gjs/commit/27b6fb1e65e01a859861b38a89200e62bb65343a))
97 |
98 |
99 |
100 | # [2.3.0](https://github.com/PBug90/w3gjs/compare/v2.2.2...v2.3.0) (2020-12-18)
101 |
102 | ### Bug Fixes
103 |
104 | - ignore and log encountered CommandDataBlocks for unknown players (nwg) ([#72](https://github.com/PBug90/w3gjs/issues/72)) ([7a09b79](https://github.com/PBug90/w3gjs/commit/7a09b79))
105 |
106 | ### Features
107 |
108 | - read and add checksumSha1 of map to parser output ([#70](https://github.com/PBug90/w3gjs/issues/70)) ([cd4994d](https://github.com/PBug90/w3gjs/commit/cd4994d))
109 |
110 |
111 |
112 | ## [2.2.2](https://github.com/PBug90/w3gjs/compare/v2.2.1...v2.2.2) (2020-12-18)
113 |
114 | ### Bug Fixes
115 |
116 | - correctly handle protobuf metadata ([#68](https://github.com/PBug90/w3gjs/issues/68)) ([3104fc0](https://github.com/PBug90/w3gjs/commit/3104fc0))
117 |
118 |
119 |
120 | ## [2.2.1](https://github.com/PBug90/w3gjs/compare/v2.2.0...v2.2.1) (2020-12-10)
121 |
122 | ### Bug Fixes
123 |
124 | - adds groupHotkeys to player json serialization ([#66](https://github.com/PBug90/w3gjs/issues/66)) ([9b3988f](https://github.com/PBug90/w3gjs/commit/9b3988f))
125 |
126 |
127 |
128 | # [2.2.0](https://github.com/PBug90/w3gjs/compare/v2.1.0...v2.2.0) (2020-11-04)
129 |
130 | ### Bug Fixes
131 |
132 | - corrected typo of basic_replay_information event in W3GReplay ([#64](https://github.com/PBug90/w3gjs/issues/64)) ([39515ab](https://github.com/PBug90/w3gjs/commit/39515ab))
133 |
134 | ### Features
135 |
136 | - calculate group hotkeys for players and add to parser output([#63](https://github.com/PBug90/w3gjs/issues/63)) ([0fdb560](https://github.com/PBug90/w3gjs/commit/0fdb560))
137 | - move building upgradesfrom units to buildings in parser output ([#61](https://github.com/PBug90/w3gjs/issues/61)) ([de29d08](https://github.com/PBug90/w3gjs/commit/de29d08))
138 |
139 |
140 |
141 | # [2.1.0](https://github.com/PBug90/w3gjs/compare/v2.0.0...v2.1.0) (2020-08-26)
142 |
143 | ### Bug Fixes
144 |
145 | - use correct bit positions for map settings ([#60](https://github.com/PBug90/w3gjs/issues/60)) ([9ebdbc4](https://github.com/PBug90/w3gjs/commit/9ebdbc4))
146 |
147 | ### Features
148 |
149 | - parse player resource trading actions ([#58](https://github.com/PBug90/w3gjs/issues/58)) ([4568cb6](https://github.com/PBug90/w3gjs/commit/4568cb6))
150 |
151 |
152 |
153 | # [2.0.0](https://github.com/PBug90/w3gjs/compare/v1.7.2...v2.0.0) (2020-08-16)
154 |
155 | ### Features
156 |
157 | - version 2.0 with new parser and async interface ([4eedbff](https://github.com/PBug90/w3gjs/commit/4eedbff))
158 |
159 | ### BREAKING CHANGES
160 |
161 | - introduce version 2.0
162 |
163 | - feat: async replay parser interface
164 |
165 | - refactor: use composition instead of inheritance, working async parser
166 |
167 | - improvement: prepare 2.0, use prettier, remove rollupjs
168 |
169 | - style: formatting
170 |
171 | - improvement: proper tsconfig, fix linting errors
172 |
173 | - cicd: remove nodejs 9 from build pipeline
174 |
175 | - test: change testfile layout, use one parser for Reforged and Netease
176 |
177 | - improvement: remove Platform parameter requirement
178 |
179 | - improvement: better action typings, remove formatters from parsers
180 |
181 | - style: remove CR as suggested by prettier
182 |
183 | - improvement: code formatting
184 |
185 | - improvement: use package.lockfile
186 |
187 | - improvement: better parser typings
188 |
189 | - improvement: typings for GameDataBlocks
190 |
191 | - improvement: some more typescript refactoring
192 |
193 | - improvement: remove the custom types for binary-parser
194 |
195 | - improvement: only use async replay parsing interface, new parser classes
196 |
197 | - refactor: remove obsolete files
198 |
199 | - refactor: make typings comply with linter
200 |
201 | - improvement: non-binary parser action parsing
202 |
203 | - refactor: implement action parsing, connect with W3GReplay
204 |
205 | - chore: remove unused dependencies, update remaining
206 |
207 | - refactor: use composition if where mixin was used
208 |
209 | - chore: set up github pages with typedoc
210 |
211 | - docs: .nojekyll to enable proper typedoc serving
212 |
213 | - chore: configuration for transpilation to commonjs
214 |
215 | - improvement: remove redundant examples directory
216 |
217 | - docs: README update
218 |
219 | - docs: add examples folder
220 |
221 | - docs: update README
222 |
223 | - chore: deploy github pages after all test jobs passed
224 |
225 | - improvement: player class toJSON, generate sample output from test
226 |
227 |
228 |
229 | ## [1.7.2](https://github.com/PBug90/w3gjs/compare/v1.7.1...v1.7.2) (2020-06-07)
230 |
231 | ### Bug Fixes
232 |
233 | - make extraPlayerList optional in reforged metadata ([#54](https://github.com/PBug90/w3gjs/issues/54)) ([c07cfd3](https://github.com/PBug90/w3gjs/commit/c07cfd3))
234 |
235 |
236 |
237 | ## [1.7.1](https://github.com/PBug90/w3gjs/compare/v1.7.0...v1.7.1) (2020-04-28)
238 |
239 | ### Bug Fixes
240 |
241 | - ignore reforged extra player if not existant in vanilla player list [#51](https://github.com/PBug90/w3gjs/issues/51) ([#52](https://github.com/PBug90/w3gjs/issues/52)) ([b127a76](https://github.com/PBug90/w3gjs/commit/b127a76))
242 |
243 |
244 |
245 | # [1.7.0](https://github.com/PBug90/w3gjs/compare/v1.6.2...v1.7.0) (2020-03-03)
246 |
247 | ### Bug Fixes
248 |
249 | - replace classic player names with reforged metadata names ([#49](https://github.com/PBug90/w3gjs/issues/49)) ([36a6d0c](https://github.com/PBug90/w3gjs/commit/36a6d0c))
250 |
251 | ### Features
252 |
253 | - parse netease 1.32 replays ([#46](https://github.com/PBug90/w3gjs/issues/46)) ([e436f32](https://github.com/PBug90/w3gjs/commit/e436f32))
254 |
255 |
256 |
257 | ## [1.6.2](https://github.com/PBug90/w3gjs/compare/v1.6.1...v1.6.2) (2020-02-04)
258 |
259 | ### Bug Fixes
260 |
261 | - convert alphanumeric action values with base 10 instead of 16 ([#44](https://github.com/PBug90/w3gjs/issues/44)) ([81c41c8](https://github.com/PBug90/w3gjs/commit/81c41c8))
262 |
263 |
264 |
265 | ## [1.6.1](https://github.com/PBug90/w3gjs/compare/v1.6.0...v1.6.1) (2020-02-03)
266 |
267 | ### Bug Fixes
268 |
269 | - handle string of length >=1 between gamename and encoded string ([#42](https://github.com/PBug90/w3gjs/issues/42)) ([612e443](https://github.com/PBug90/w3gjs/commit/612e443))
270 |
271 |
272 |
273 | # [1.6.0](https://github.com/PBug90/w3gjs/compare/v1.5.2...v1.6.0) (2020-01-29)
274 |
275 | ### Features
276 |
277 | - parse reforged replays successfully ([#39](https://github.com/PBug90/w3gjs/issues/39)) ([2dfa447](https://github.com/PBug90/w3gjs/commit/2dfa447))
278 |
279 |
280 |
281 | ## [1.5.2](https://github.com/PBug90/w3gjs/compare/v1.5.1...v1.5.2) (2020-01-08)
282 |
283 | ### Bug Fixes
284 |
285 | - improved APM calculation accuracy ([107b7ab](https://github.com/PBug90/w3gjs/commit/107b7ab))
286 |
287 |
288 |
289 | ## [1.5.1](https://github.com/PBug90/w3gjs/compare/v1.5.0...v1.5.1) (2020-01-02)
290 |
291 | ### Bug Fixes
292 |
293 | - corrections to playerColor values ([#36](https://github.com/PBug90/w3gjs/issues/36)) ([0e08b96](https://github.com/PBug90/w3gjs/commit/0e08b96))
294 |
295 |
296 |
297 | # [1.5.0](https://github.com/PBug90/w3gjs/compare/v1.4.1...v1.5.0) (2019-12-05)
298 |
299 | ### Features
300 |
301 | - parse but skip 0x7a actions ([#34](https://github.com/PBug90/w3gjs/issues/34)) ([bfe1980](https://github.com/PBug90/w3gjs/commit/bfe1980))
302 |
303 |
304 |
305 | ## [1.4.1](https://github.com/PBug90/w3gjs/compare/v1.4.0...v1.4.1) (2019-11-30)
306 |
307 |
308 |
309 | # [1.4.0](https://github.com/PBug90/w3gjs/compare/v1.3.0...v1.4.0) (2019-11-30)
310 |
311 | ### Features
312 |
313 | - allow buffer as input ([#31](https://github.com/PBug90/w3gjs/issues/31)) ([edef518](https://github.com/PBug90/w3gjs/commit/edef518))
314 |
315 |
316 |
317 | # [1.3.0](https://github.com/PBug90/w3gjs/compare/v1.2.0...v1.3.0) (2019-08-28)
318 |
319 | ### Bug Fixes
320 |
321 | - properly converts maps with backslash and/or forward slash ([a143318](https://github.com/PBug90/w3gjs/commit/a143318))
322 |
323 | ### Features
324 |
325 | - track parse time and added it to parser output ([e06bfb2](https://github.com/PBug90/w3gjs/commit/e06bfb2))
326 |
327 |
328 |
329 | # [1.2.0](https://github.com/anXieTyPB/w3gjs/compare/v1.1.3...v1.2.0) (2019-08-22)
330 |
331 | ### Features
332 |
333 | - detect tome of retraining uses ([#26](https://github.com/anXieTyPB/w3gjs/issues/26)) ([7e96e9d](https://github.com/anXieTyPB/w3gjs/commit/7e96e9d))
334 |
335 |
336 |
337 | ## [1.1.3](https://github.com/anXieTyPB/w3gjs/compare/v1.1.2...v1.1.3) (2019-08-10)
338 |
339 | ### Bug Fixes
340 |
341 | - correctly sort output players by teamid and then by player id ([#23](https://github.com/anXieTyPB/w3gjs/issues/23)) ([a0edb47](https://github.com/anXieTyPB/w3gjs/commit/a0edb47))
342 |
343 |
344 |
345 | ## [1.1.2](https://github.com/anXieTyPB/w3gjs/compare/v1.1.1...v1.1.2) (2019-04-26)
346 |
347 | ### Bug Fixes
348 |
349 | - msElapsed is now reset to 0 between multiple parses ([#21](https://github.com/anXieTyPB/w3gjs/issues/21)) ([6a6b4c7](https://github.com/anXieTyPB/w3gjs/commit/6a6b4c7))
350 |
351 |
352 |
353 | ## [1.1.1](https://github.com/anXieTyPB/w3gjs/compare/v1.1.0...v1.1.1) (2019-03-07)
354 |
355 | ### Bug Fixes
356 |
357 | - parse 0x22 block length as unsigned int ([0a5bd4c](https://github.com/anXieTyPB/w3gjs/commit/0a5bd4c))
358 |
359 |
360 |
361 | # [1.1.0](https://github.com/anXieTyPB/w3gjs/compare/v1.0.2...v1.1.0) (2019-03-07)
362 |
363 | ### Features
364 |
365 | - new low level EventEmitter API to emit events for replay blocks ([#20](https://github.com/anXieTyPB/w3gjs/issues/20)) ([b476e5d](https://github.com/anXieTyPB/w3gjs/commit/b476e5d))
366 |
367 |
368 |
369 | ## [1.0.2](https://github.com/anXieTyPB/w3gjs/compare/v1.0.1...v1.0.2) (2019-01-22)
370 |
371 | ### Bug Fixes
372 |
373 | - added dedicated game version formatting function ([#19](https://github.com/anXieTyPB/w3gjs/issues/19)) ([1c3b2cd](https://github.com/anXieTyPB/w3gjs/commit/1c3b2cd))
374 |
375 |
376 |
377 | ## [1.0.1](https://github.com/anXieTyPB/w3gjs/compare/v1.0.0...v1.0.1) (2019-01-09)
378 |
379 |
380 |
381 | # 1.0.0 (2019-01-07)
382 |
383 | ### Bug Fixes
384 |
385 | - chat scope is parsed and formatted correctly ([393f9b2](https://github.com/anXieTyPB/w3gjs/commit/393f9b2))
386 | - chatlog shape corrected ([ee53110](https://github.com/anXieTyPB/w3gjs/commit/ee53110))
387 | - parse 1.30.2 replays, added 0x22 dynamic block length ([#11](https://github.com/anXieTyPB/w3gjs/issues/11)) ([1c7bfed](https://github.com/anXieTyPB/w3gjs/commit/1c7bfed))
388 | - parse action click coordinates as float instead of int ([#16](https://github.com/anXieTyPB/w3gjs/issues/16)) ([06722a8](https://github.com/anXieTyPB/w3gjs/commit/06722a8))
389 | - parse building-objectids correctly ([65475c0](https://github.com/anXieTyPB/w3gjs/commit/65475c0))
390 | - remove observers array from teams property ([6d4e040](https://github.com/anXieTyPB/w3gjs/commit/6d4e040))
391 |
392 | ### Features
393 |
394 | - action tracking complies with php parser apm standard ([7a47c74](https://github.com/anXieTyPB/w3gjs/commit/7a47c74))
395 | - add player color conversion ([d9f921a](https://github.com/anXieTyPB/w3gjs/commit/d9f921a))
396 | - allow single player games to be parsed successfully ([c626119](https://github.com/anXieTyPB/w3gjs/commit/c626119))
397 | - average player apm calculation ([fdb82fa](https://github.com/anXieTyPB/w3gjs/commit/fdb82fa))
398 | - detect normalized matchup ([5c943b0](https://github.com/anXieTyPB/w3gjs/commit/5c943b0))
399 | - introduced new parser output schema ([#8](https://github.com/anXieTyPB/w3gjs/issues/8)) ([80d6b28](https://github.com/anXieTyPB/w3gjs/commit/80d6b28))
400 | - parse player items, fix for nwg padding at end of file ([c57d21a](https://github.com/anXieTyPB/w3gjs/commit/c57d21a))
401 | - use mapping to differ units / buildings / upgrades / items ([6ed265b](https://github.com/anXieTyPB/w3gjs/commit/6ed265b))
402 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-2020 PBug90
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # w3gjs
2 |
3 | [](https://conventionalcommits.org)
4 | [](https://github.com/PBug90/w3gjs/actions/workflows/testandbuild.yml)
5 | [](https://qlty.sh/gh/PBug90/projects/w3gjs)
6 | [](https://qlty.sh/gh/PBug90/projects/w3gjs)
7 |
8 | ## Parser
9 |
10 | From scratch asynchronous, fully typed and tested TypeScript implementation of a w3g parser for WarCraft 3 replay files.
11 |
12 | You can use the subcomponents to create your own parser that suits your requirements or just use the high-level parser output that is best suited for
13 | standard game mode game analysis.
14 |
15 | **It does not fully support replays of game version <= 1.14.**
16 |
17 | ## Installation
18 |
19 | ```
20 | npm install w3gjs
21 | ```
22 |
23 | ## Usage
24 |
25 | Check out the examples folder of this repository to find usage examples for both typescript and javascript.
26 |
27 | For detailed API documentation check out https://pbug90.github.io/w3gjs
28 | Please keep in mind that as of now, parallel parsing with the same W3GReplay instance is not yet supported. Instantiate multiple instances or parse replays sequentially.
29 |
30 | ### High Level API
31 |
32 | High level API is best suited to parse standard melee replays.
33 |
34 | ```javascript
35 | const W3GReplay = require("w3gjs").default;
36 | const parser = new W3GReplay();
37 |
38 | (async () => {
39 | const result = await parser.parse("replay.w3g");
40 | console.log(result);
41 | })().catch(console.error);
42 | ```
43 |
44 | ### Low Level API
45 |
46 | Low level API allows you to either implement your own logic on top of the ReplayParser class by extending it or
47 | to register callbacks to listen for parser events as it encounters the different kinds of blocks in a replay.
48 |
49 | In previous versions, multiple events were emitted. In version 2 there are exactly two events.
50 |
51 | **basic_replay_information** provides you with metadata about the replay
52 | that was parsed from the header information.
53 |
54 | The **gamedatablock** event provides you with all blocks that make up the actual game data, fully parsed and in correct order. You can check their _id_ property to distinguish the blocks from each other. For more information, consult the auto-generated docs for properties of specific blocks.
55 |
56 | ```javascript
57 | const ReplayParser = require("w3gjs/dist/lib/parsers/ReplayParser").default;
58 | const fs = require("fs");
59 | (async () => {
60 | const buffer = fs.readFileSync("./reforged1.w3g");
61 | const parser = new ReplayParser();
62 | parser.on("basic_replay_information", (info) => console.log(info));
63 | parser.on("gamedatablock", (block) => console.log(block));
64 | const result = await parser.parse(buffer);
65 | console.log(result);
66 | })().catch(console.error);
67 | ```
68 |
69 | ## Contributing
70 |
71 | There is no point in hiding the implementation of tools that the community can use. So please feel free to discuss in the issues section or provide a pull request if you think you can improve this parser.
72 |
73 | ## Issues
74 |
75 | If you have an issue using this library please use the issue section and provide an example replay file.
76 |
77 | ## License
78 |
79 | MIT license, see LICENSE.md file.
80 |
--------------------------------------------------------------------------------
/docs/algorithms.md:
--------------------------------------------------------------------------------
1 | # identifier algorithm
2 |
3 | In order to detect duplicate replays, an algorithm can be used to determine a unique identifier for each replay file.
4 | That unique identifier must not rely on actual game data or game outcome and must only rely on information that is shared across all game participants regardless of time of replay saving.
5 | Since replays of the same game can differ in binary (players leaving earlier than other players), a file hash is of no use. But one can use existing replay information to construct a hash as follows:
6 |
7 | - create a list of all playing players that are NOT observers or referees
8 | - for each player in this list, create a string for each player following this schema: {playername}_{playerid}_{teamid}
9 | - sort the resulting list of strings alphabetically in ascending order
10 | - concat all strings of the sorted list to form a list of player information without spaces
11 | - concat the randomseed string representation with string generated in the previous step and the full map path
12 | - use sha256 to generate a hash of the resulting string of the previous step
13 |
14 | ## example implementation
15 |
16 | ```typescript
17 | generateID(): void {
18 | let players = Object.values(this.players).filter((p) => this.isObserver(p) === false).sort((player1, player2) => {
19 | if (player1.id < player2.id) {
20 | return -1
21 | }
22 | return 1
23 | }).reduce((accumulator, player) => {
24 | accumulator += `${player.name}_${player.id}_${player.teamid}`
25 | return accumulator
26 | }, '')
27 | const idBase = this.meta.meta.randomSeed + players + this.meta.mapName
28 | this.id = createHash('sha256').update(idBase).digest('hex')
29 | }
30 | ```
31 |
32 | # game type algorithm
33 |
34 | # matchup algorithm
35 |
36 | For a given matchup, the matchup algorithm is supposed to return the same string regardless of player or team order.
37 | Consider the following example:
38 |
39 | - Team1: Undead + Human
40 | - Team0: Orc + Human
41 |
42 | The resulting matchup string must be HOvHU
43 |
44 | Algorithm:
45 |
46 | - for each team, get the first letter of the race of each player and uppercase it
47 | - in that team, sort by race letter ascending
48 | - for each sorted team race combination, sort the team race combinations ascending
49 | - concat the resulting team race combinations with a 'v' to form the final matchup string
50 |
51 | ```typescript
52 | determineMatchup(): void {
53 | let teamRaces = {}
54 | Object.values(this.players).forEach((p) => {
55 | if (!this.isObserver(p)) {
56 | teamRaces[p.teamid] = teamRaces[p.teamid] || []
57 | teamRaces[p.teamid].push(p.detectedRace || p.race)
58 | }
59 | })
60 | this.matchup = (Object.values(teamRaces).map(e => e.sort().join(''))).sort().join('v')
61 | }
62 | ```
63 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "eslint/config";
2 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
3 | import prettier from "eslint-plugin-prettier";
4 | import tsParser from "@typescript-eslint/parser";
5 | import path from "node:path";
6 | import { fileURLToPath } from "node:url";
7 | import js from "@eslint/js";
8 | import { FlatCompat } from "@eslint/eslintrc";
9 |
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = path.dirname(__filename);
12 | const compat = new FlatCompat({
13 | baseDirectory: __dirname,
14 | recommendedConfig: js.configs.recommended,
15 | allConfig: js.configs.all,
16 | });
17 |
18 | export default defineConfig([
19 | {
20 | extends: compat.extends(
21 | "plugin:@typescript-eslint/recommended",
22 | "plugin:prettier/recommended",
23 | ),
24 |
25 | plugins: {
26 | "@typescript-eslint": typescriptEslint,
27 | prettier,
28 | },
29 |
30 | languageOptions: {
31 | parser: tsParser,
32 | },
33 |
34 | settings: {
35 | "import/resolver": {
36 | node: {
37 | extensions: [".js", ".ts"],
38 | },
39 | },
40 | },
41 |
42 | rules: {
43 | "no-console": [
44 | "error",
45 | {
46 | allow: ["warn", "error"],
47 | },
48 | ],
49 | "@typescript-eslint/no-unused-vars": [
50 | "error",
51 | {
52 | ignoreRestSiblings: true,
53 | },
54 | ],
55 | },
56 | },
57 | ]);
58 |
--------------------------------------------------------------------------------
/examples/highlevel.js:
--------------------------------------------------------------------------------
1 | /*
2 | This snippet passses a file system path to the high level parser
3 | and logs the parser result.
4 | */
5 | const { default: W3GReplay } = require("w3gjs");
6 | const parser = new W3GReplay();
7 | parser
8 | .parse("./replay.w3g")
9 | .then((result) => {
10 | console.log(result);
11 | })
12 | .catch(console.error);
13 |
--------------------------------------------------------------------------------
/examples/highlevel.ts:
--------------------------------------------------------------------------------
1 | /*
2 | This snippet passses a file system path to the high level parser
3 | and logs the parser result.
4 | */
5 | import W3GReplay from "w3gjs";
6 | const parser = new W3GReplay();
7 | parser
8 | .parse("./replay.w3g")
9 | .then((result) => {
10 | console.log(result);
11 | })
12 | .catch(console.error);
13 |
--------------------------------------------------------------------------------
/examples/lowlevel.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/examples/lowlevel.js
--------------------------------------------------------------------------------
/examples/lowlevel.ts:
--------------------------------------------------------------------------------
1 | /*
2 | This snippet uses the ReplayParser gamedatablock event
3 | to log all player actions in the game to the console
4 | */
5 | import { ReplayParser } from "w3gjs";
6 | import { readFileSync } from "fs";
7 | const parser = new ReplayParser();
8 | parser.on("gamedatablock", (block) => {
9 | if (block.id === 0x1f) {
10 | block.commandBlocks.forEach((commandBlock) => {
11 | console.log(
12 | commandBlock.playerId +
13 | " dispatched actions: " +
14 | JSON.stringify(commandBlock.actions),
15 | );
16 | });
17 | }
18 | });
19 | parser
20 | .parse(readFileSync("./replay.w3g"))
21 | .then((result) => {
22 | console.log(result);
23 | })
24 | .catch(console.error);
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "w3gjs",
3 | "version": "3.0.0",
4 | "description": "A native JavaScript WarCraft 3 replay parser implementation.",
5 | "main": "dist/lib/index.js",
6 | "module": "dist/W3GReplay.es5.js",
7 | "types": "dist/types/index.d.ts",
8 | "files": [
9 | "dist",
10 | "index.js"
11 | ],
12 | "engines": {
13 | "node": ">=18.x"
14 | },
15 | "scripts": {
16 | "test": "jest --config test/jest.config.js",
17 | "test:coverage": "jest --collectCoverage --config test/jest.config.js",
18 | "lint": "eslint --ext .ts,.js src/ test/ && prettier --check .",
19 | "lint:fix": "eslint --ext .jsx,.js src/** --fix",
20 | "build": "tsc"
21 | },
22 | "dependencies": {
23 | "protobufjs": "^7.4.0"
24 | },
25 | "devDependencies": {
26 | "@eslint/eslintrc": "^3.3.1",
27 | "@eslint/js": "^9.24.0",
28 | "@types/jest": "^29.5.13",
29 | "@types/node": "^20.16.11",
30 | "@typescript-eslint/eslint-plugin": "^8.9.0",
31 | "@typescript-eslint/parser": "^8.9.0",
32 | "eslint": "^9.24.0",
33 | "eslint-config-prettier": "^10.1.2",
34 | "eslint-plugin-prettier": "^5.2.1",
35 | "jest": "^29.7.0",
36 | "jsonschema": "^1.4.1",
37 | "prettier": "^3.3.3",
38 | "ts-jest": "^29.2.5",
39 | "typescript": "^5.6.3"
40 | },
41 | "repository": {
42 | "type": "git",
43 | "url": "git+https://github.com/PBug90/w3gjs.git"
44 | },
45 | "keywords": [
46 | "w3g",
47 | "warcraft",
48 | "replay",
49 | "parser"
50 | ],
51 | "author": "PBug90",
52 | "license": "MIT",
53 | "bugs": {
54 | "url": "https://github.com/PBug90/w3gjs/issues"
55 | },
56 | "homepage": "https://github.com/PBug90/w3gjs#readme"
57 | }
58 |
--------------------------------------------------------------------------------
/src/Player.ts:
--------------------------------------------------------------------------------
1 | import convert from "./convert";
2 | import { items, units, buildings, upgrades, abilityToHero } from "./mappings";
3 | import { Race, ItemID } from "./types";
4 | import { TransferResourcesActionWithPlayer } from "./W3GReplay";
5 | import { Action } from "./parsers/ActionParser";
6 | import { inferHeroAbilityLevelsFromAbilityOrder } from "./inferHeroAbilityLevelsFromAbilityOrder";
7 | import { getRetrainingIndex } from "./detectRetraining";
8 |
9 | const isRightclickAction = (input: number[]) =>
10 | input[0] === 0x03 && input[1] === 0;
11 | const isBasicAction = (input: number[]) => input[0] <= 0x19 && input[1] === 0;
12 |
13 | type TransferResourcesActionWithPlayerAndTimestamp = {
14 | msElapsed: number;
15 | } & TransferResourcesActionWithPlayer;
16 |
17 | export interface Ability {
18 | type: "ability";
19 | time: number;
20 | value: string;
21 | }
22 |
23 | export interface Retraining {
24 | type: "retraining";
25 | time: number;
26 | }
27 |
28 | export interface HeroInfo {
29 | level: number;
30 | abilities: { [key: string]: number };
31 | order: number;
32 | id: string;
33 | retrainingHistory: { time: number; abilities: { [key: string]: number } }[];
34 | abilityOrder: (Ability | Retraining)[];
35 | }
36 |
37 | class Player {
38 | id: number;
39 | name: string;
40 | teamid: number;
41 | color: string;
42 | race: Race;
43 | raceDetected: string;
44 | units: {
45 | summary: { [key: string]: number };
46 | order: { id: string; ms: number }[];
47 | };
48 | upgrades: {
49 | summary: { [key: string]: number };
50 | order: { id: string; ms: number }[];
51 | };
52 | items: {
53 | summary: { [key: string]: number };
54 | order: { id: string; ms: number }[];
55 | };
56 |
57 | buildings: {
58 | summary: { [key: string]: number };
59 | order: { id: string; ms: number }[];
60 | };
61 | heroes: Omit[];
62 | heroCollector: { [key: string]: HeroInfo };
63 | heroCount: number;
64 | actions: {
65 | timed: number[];
66 | assigngroup: number;
67 | rightclick: number;
68 | basic: number;
69 | buildtrain: number;
70 | ability: number;
71 | item: number;
72 | select: number;
73 | removeunit: number;
74 | subgroup: number;
75 | selecthotkey: number;
76 | esc: number;
77 | };
78 | groupHotkeys: {
79 | [key: number]: { assigned: number; used: number };
80 | };
81 | resourceTransfers: TransferResourcesActionWithPlayerAndTimestamp[] = [];
82 | _currentlyTrackedAPM: number;
83 | _retrainingMetadata: { [key: string]: { start: number; end: number } };
84 | _lastRetrainingTime: number;
85 | _lastActionWasDeselect: boolean;
86 | currentTimePlayed: number;
87 | apm: number;
88 |
89 | constructor(
90 | id: number,
91 | name: string,
92 | teamid: number,
93 | color: number,
94 | race: Race,
95 | ) {
96 | this.id = id;
97 | this.name = name;
98 | this.teamid = teamid;
99 | this.color = convert.playerColor(color);
100 | this.race = race;
101 | this.raceDetected = "";
102 | this.units = { summary: {}, order: [] };
103 | this.upgrades = { summary: {}, order: [] };
104 | this.items = { summary: {}, order: [] };
105 | this.buildings = { summary: {}, order: [] };
106 | this.heroes = [];
107 | this.heroCollector = {};
108 | this.resourceTransfers = [];
109 | this.heroCount = 0;
110 | this.actions = {
111 | timed: [],
112 | assigngroup: 0,
113 | rightclick: 0,
114 | basic: 0,
115 | buildtrain: 0,
116 | ability: 0,
117 | item: 0,
118 | select: 0,
119 | removeunit: 0,
120 | subgroup: 0,
121 | selecthotkey: 0,
122 | esc: 0,
123 | };
124 | this.groupHotkeys = {
125 | 1: { assigned: 0, used: 0 },
126 | 2: { assigned: 0, used: 0 },
127 | 3: { assigned: 0, used: 0 },
128 | 4: { assigned: 0, used: 0 },
129 | 5: { assigned: 0, used: 0 },
130 | 6: { assigned: 0, used: 0 },
131 | 7: { assigned: 0, used: 0 },
132 | 8: { assigned: 0, used: 0 },
133 | 9: { assigned: 0, used: 0 },
134 | 0: { assigned: 0, used: 0 },
135 | };
136 | this._currentlyTrackedAPM = 0;
137 | this._lastActionWasDeselect = false;
138 | this._retrainingMetadata = {};
139 | this._lastRetrainingTime = 0;
140 | this.currentTimePlayed = 0;
141 | this.apm = 0;
142 | }
143 |
144 | newActionTrackingSegment(timeTrackingInterval = 60000): void {
145 | this.actions.timed.push(
146 | Math.floor(this._currentlyTrackedAPM * (60000.0 / timeTrackingInterval)),
147 | );
148 | this._currentlyTrackedAPM = 0;
149 | }
150 |
151 | detectRaceByActionId(actionId: string): void {
152 | switch (actionId[0]) {
153 | case "e":
154 | this.raceDetected = "N";
155 | break;
156 | case "o":
157 | this.raceDetected = "O";
158 | break;
159 | case "h":
160 | this.raceDetected = "H";
161 | break;
162 | case "u":
163 | this.raceDetected = "U";
164 | break;
165 | }
166 | }
167 |
168 | handleStringencodedItemID(actionId: string, gametime: number): void {
169 | if (units[actionId]) {
170 | this.units.summary[actionId] = this.units.summary[actionId] + 1 || 1;
171 | this.units.order.push({ id: actionId, ms: gametime });
172 | } else if (items[actionId]) {
173 | this.items.summary[actionId] = this.items.summary[actionId] + 1 || 1;
174 | this.items.order.push({ id: actionId, ms: gametime });
175 | } else if (buildings[actionId]) {
176 | this.buildings.summary[actionId] =
177 | this.buildings.summary[actionId] + 1 || 1;
178 | this.buildings.order.push({ id: actionId, ms: gametime });
179 | } else if (upgrades[actionId]) {
180 | this.upgrades.summary[actionId] =
181 | this.upgrades.summary[actionId] + 1 || 1;
182 | this.upgrades.order.push({ id: actionId, ms: gametime });
183 | }
184 | }
185 |
186 | handleHeroSkill(actionId: string, gametime: number): void {
187 | const heroId = abilityToHero[actionId];
188 | if (this.heroCollector[heroId] === undefined) {
189 | this.heroCount += 1;
190 | this.heroCollector[heroId] = {
191 | level: 0,
192 | abilities: {},
193 | order: this.heroCount,
194 | id: heroId,
195 | abilityOrder: [],
196 | retrainingHistory: [],
197 | };
198 | }
199 | this.heroCollector[heroId].abilityOrder.push({
200 | type: "ability",
201 | time: gametime,
202 | value: actionId,
203 | });
204 | if (this._lastRetrainingTime > 0) {
205 | const retrainingIndex = getRetrainingIndex(
206 | this.heroCollector[heroId].abilityOrder,
207 | this._lastRetrainingTime,
208 | );
209 |
210 | if (retrainingIndex >= 0) {
211 | this.heroCollector[heroId].abilityOrder.splice(retrainingIndex, 0, {
212 | type: "retraining",
213 | time: this._lastRetrainingTime,
214 | });
215 | this._lastRetrainingTime = 0;
216 | }
217 | }
218 | }
219 |
220 | handleRetraining(gametime: number): void {
221 | this._lastRetrainingTime = gametime;
222 | }
223 |
224 | handle0x10(itemid: ItemID, gametime: number): void {
225 | switch (itemid.value[0]) {
226 | case "A":
227 | this.handleHeroSkill(itemid.value as string, gametime);
228 | break;
229 | case "R":
230 | this.handleStringencodedItemID(itemid.value as string, gametime);
231 | break;
232 | case "u":
233 | case "e":
234 | case "h":
235 | case "o":
236 | if (!this.raceDetected) {
237 | this.detectRaceByActionId(itemid.value as string);
238 | }
239 | this.handleStringencodedItemID(itemid.value as string, gametime);
240 | break;
241 | default:
242 | this.handleStringencodedItemID(itemid.value as string, gametime);
243 | }
244 |
245 | if (itemid.value[0] !== "0") {
246 | this.actions.buildtrain++;
247 | } else {
248 | this.actions.ability++;
249 | }
250 |
251 | this._currentlyTrackedAPM++;
252 | }
253 |
254 | handle0x11(itemid: ItemID, gametime: number): void {
255 | this._currentlyTrackedAPM++;
256 | if (itemid.type === "alphanumeric") {
257 | if (itemid.value[0] <= 0x19 && itemid.value[1] === 0) {
258 | this.actions.basic++;
259 | } else {
260 | this.actions.ability++;
261 | }
262 | } else {
263 | this.handleStringencodedItemID(itemid.value as string, gametime);
264 | }
265 | }
266 |
267 | handle0x12(itemid: ItemID, gametime: number): void {
268 | if (isRightclickAction(itemid.value as number[])) {
269 | this.actions.rightclick++;
270 | } else if (isBasicAction(itemid.value as number[])) {
271 | this.actions.basic++;
272 | } else {
273 | this.actions.ability++;
274 | }
275 | if (itemid.type === "stringencoded") {
276 | this.handleStringencodedItemID(itemid.value, gametime);
277 | }
278 | this._currentlyTrackedAPM++;
279 | }
280 |
281 | handle0x13(): void {
282 | this.actions.item++;
283 | this._currentlyTrackedAPM++;
284 | }
285 |
286 | handle0x14(itemid: ItemID): void {
287 | if (isRightclickAction(itemid.value as number[])) {
288 | this.actions.rightclick++;
289 | } else if (isBasicAction(itemid.value as number[])) {
290 | this.actions.basic++;
291 | } else {
292 | this.actions.ability++;
293 | }
294 | this._currentlyTrackedAPM++;
295 | }
296 |
297 | handle0x16(selectMode: number, isAPM: boolean): void {
298 | if (isAPM) {
299 | this.actions.select++;
300 | this._currentlyTrackedAPM++;
301 | }
302 | }
303 |
304 | handle0x51(action: TransferResourcesActionWithPlayer): void {
305 | this.resourceTransfers.push({
306 | ...action,
307 | msElapsed: this.currentTimePlayed,
308 | });
309 | }
310 |
311 | handleOther(action: Action): void {
312 | switch (action.id) {
313 | case 0x17:
314 | this.actions.assigngroup++;
315 | this._currentlyTrackedAPM++;
316 | this.groupHotkeys[(action.groupNumber + 1) % 10].assigned++;
317 | break;
318 | case 0x18:
319 | this.actions.selecthotkey++;
320 | this._currentlyTrackedAPM++;
321 | this.groupHotkeys[(action.groupNumber + 1) % 10].used++;
322 | break;
323 | case 0x1c:
324 | case 0x1d:
325 | case 0x66:
326 | case 0x67:
327 | this._currentlyTrackedAPM++;
328 | break;
329 | case 0x1e:
330 | this.actions.removeunit++;
331 | this._currentlyTrackedAPM++;
332 | break;
333 | case 0x61:
334 | this.actions.esc++;
335 | this._currentlyTrackedAPM++;
336 | break;
337 | }
338 | }
339 |
340 | determineHeroLevelsAndHandleRetrainings() {
341 | const heroInfo: Omit[] = [];
342 | for (const { order, ...hero } of Object.values(this.heroCollector).sort(
343 | (h1, h2) => h1.order - h2.order,
344 | )) {
345 | const inferredAbilityInfo = inferHeroAbilityLevelsFromAbilityOrder(
346 | hero.abilityOrder,
347 | );
348 | hero.abilities = inferredAbilityInfo.finalHeroAbilities;
349 | hero.retrainingHistory = inferredAbilityInfo.retrainingHistory;
350 | hero.level = Object.values(hero.abilities).reduce(
351 | (prev, curr) => prev + curr,
352 | 0,
353 | );
354 | heroInfo.push(hero);
355 | }
356 | this.heroes = heroInfo;
357 | }
358 |
359 | cleanup(): void {
360 | const apmSum = this.actions.timed.reduce(
361 | (a: number, b: number): number => a + b,
362 | );
363 | if (this.currentTimePlayed === 0) {
364 | this.apm = 0;
365 | } else {
366 | this.apm = Math.round(apmSum / (this.currentTimePlayed / 1000 / 60));
367 | }
368 | this.determineHeroLevelsAndHandleRetrainings();
369 | }
370 |
371 | toJSON(): Partial {
372 | return {
373 | actions: this.actions,
374 | groupHotkeys: this.groupHotkeys,
375 | buildings: this.buildings,
376 | items: this.items,
377 | units: this.units,
378 | upgrades: this.upgrades,
379 | color: this.color,
380 | heroes: this.heroes,
381 | name: this.name,
382 | race: this.race,
383 | raceDetected: this.raceDetected,
384 | teamid: this.teamid,
385 | apm: this.apm,
386 | id: this.id,
387 | resourceTransfers: this.resourceTransfers,
388 | };
389 | }
390 | }
391 |
392 | export default Player;
393 |
--------------------------------------------------------------------------------
/src/W3GReplay.ts:
--------------------------------------------------------------------------------
1 | import Player from "./Player";
2 | import convert from "./convert";
3 | import { objectIdFormatter, raceFlagFormatter } from "./parsers/formatters";
4 | import { ParserOutput } from "./types";
5 | import { sortPlayers } from "./sort";
6 | import { EventEmitter } from "node:events";
7 | import console from "node:console";
8 | import { createHash } from "node:crypto";
9 | import { performance } from "node:perf_hooks";
10 | import { readFile } from "node:fs";
11 | import { promisify } from "node:util";
12 | import ReplayParser, {
13 | ParserOutput as ReplayParserOutput,
14 | BasicReplayInformation,
15 | } from "./parsers/ReplayParser";
16 | import {
17 | GameDataBlock,
18 | PlayerChatMessageBlock,
19 | TimeslotBlock,
20 | CommandBlock,
21 | LeaveGameBlock,
22 | } from "./parsers/GameDataParser";
23 | import {
24 | Action,
25 | W3MMDAction,
26 | TransferResourcesAction,
27 | } from "./parsers/ActionParser";
28 |
29 | export type TransferResourcesActionWithPlayer = {
30 | playerName: string;
31 | playerId: number;
32 | } & Omit;
33 |
34 | const readFilePromise = promisify(readFile);
35 |
36 | enum ChatMessageMode {
37 | All = "All",
38 | Private = "Private",
39 | Team = "Team",
40 | Observers = "Obervers",
41 | }
42 |
43 | export enum ObserverMode {
44 | ON_DEFEAT = "ON_DEFEAT",
45 | FULL = "FULL",
46 | REFEREES = "REFEREES",
47 | NONE = "NONE",
48 | }
49 |
50 | export type ChatMessage = {
51 | playerName: string;
52 | playerId: number;
53 | mode: ChatMessageMode;
54 | timeMS: number;
55 | message: string;
56 | };
57 |
58 | type Team = {
59 | [key: number]: number[];
60 | };
61 |
62 | export default interface W3GReplayEvents {
63 | on(event: "gamedatablock", listener: (block: GameDataBlock) => void): this;
64 | on(
65 | event: "basic_replay_information",
66 | listener: (data: BasicReplayInformation) => void,
67 | ): this;
68 | }
69 |
70 | export default class W3GReplay extends EventEmitter implements W3GReplayEvents {
71 | info: BasicReplayInformation;
72 | players: { [key: string]: Player };
73 | observers: string[];
74 | chatlog: ChatMessage[];
75 | id = "";
76 | leaveEvents: LeaveGameBlock[];
77 | w3mmd: W3MMDAction[];
78 | slots: ReplayParserOutput["metadata"]["slotRecords"];
79 | teams: Team;
80 | meta: ReplayParserOutput["metadata"];
81 | playerList: ReplayParserOutput["metadata"]["playerRecords"];
82 | totalTimeTracker = 0;
83 | timeSegmentTracker = 0;
84 | playerActionTrackInterval = 60000;
85 | gametype = "";
86 | matchup = "";
87 | parseStartTime: number;
88 | parser: ReplayParser;
89 | filename: string;
90 | buffer: Buffer;
91 | msElapsed = 0;
92 | slotToPlayerId = new Map();
93 | knownPlayerIds: Set;
94 | winningTeamId = -1;
95 |
96 | constructor() {
97 | super();
98 | this.parser = new ReplayParser();
99 |
100 | this.parser.on(
101 | "basic_replay_information",
102 | (information: BasicReplayInformation) => {
103 | this.handleBasicReplayInformation(information);
104 | this.emit("basic_replay_information", information);
105 | },
106 | );
107 | this.parser.on("gamedatablock", (block) => {
108 | this.emit("gamedatablock", block);
109 | this.processGameDataBlock(block);
110 | });
111 | }
112 |
113 | async parse($buffer: string | Buffer): Promise {
114 | this.msElapsed = 0;
115 | this.parseStartTime = performance.now();
116 | this.buffer = Buffer.from("");
117 | this.filename = "";
118 | this.id = "";
119 | this.chatlog = [];
120 | this.leaveEvents = [];
121 | this.w3mmd = [];
122 | this.players = {};
123 | this.slotToPlayerId = new Map();
124 | this.totalTimeTracker = 0;
125 | this.timeSegmentTracker = 0;
126 | this.slots = [];
127 | this.playerList = [];
128 | this.playerActionTrackInterval = 60000;
129 | if (typeof $buffer === "string") {
130 | $buffer = await readFilePromise($buffer);
131 | }
132 | await this.parser.parse($buffer);
133 |
134 | this.generateID();
135 | this.determineMatchup();
136 | this.determineWinningTeam();
137 | this.cleanup();
138 |
139 | return this.finalize();
140 | }
141 |
142 | private determineWinningTeam() {
143 | if (this.gametype === "1on1") {
144 | let winningTeamId = -1;
145 | this.leaveEvents.forEach((event, index) => {
146 | if (
147 | this.isObserver(this.players[event.playerId]) === true ||
148 | winningTeamId !== -1
149 | ) {
150 | return;
151 | }
152 | if (event.result === "09000000") {
153 | winningTeamId = this.players[event.playerId].teamid;
154 | return;
155 | }
156 | if (event.reason === "0c000000") {
157 | winningTeamId = this.players[event.playerId].teamid;
158 | }
159 | if (index === this.leaveEvents.length - 1) {
160 | winningTeamId = this.players[event.playerId].teamid;
161 | }
162 | });
163 | this.winningTeamId = winningTeamId;
164 | }
165 | }
166 |
167 | handleBasicReplayInformation(info: BasicReplayInformation): void {
168 | this.info = info;
169 | this.slots = info.metadata.slotRecords;
170 | this.playerList = info.metadata.playerRecords;
171 | this.meta = info.metadata;
172 | const tempPlayers: {
173 | [key: string]: BasicReplayInformation["metadata"]["playerRecords"][0];
174 | } = {};
175 | this.teams = [];
176 | this.players = {};
177 |
178 | this.playerList.forEach((player): void => {
179 | tempPlayers[player.playerId] = player;
180 | });
181 | if (info.metadata.reforgedPlayerMetadata.length > 0) {
182 | const extraPlayerList = info.metadata.reforgedPlayerMetadata;
183 | extraPlayerList.forEach((extraPlayer) => {
184 | if (tempPlayers[extraPlayer.playerId]) {
185 | tempPlayers[extraPlayer.playerId].playerName = extraPlayer.name;
186 | }
187 | });
188 | }
189 |
190 | this.slots.forEach((slot, index) => {
191 | if (slot.slotStatus > 1) {
192 | this.slotToPlayerId.set(index, slot.playerId);
193 | this.teams[slot.teamId] = this.teams[slot.teamId] || [];
194 | this.teams[slot.teamId].push(slot.playerId);
195 |
196 | this.players[slot.playerId] = new Player(
197 | slot.playerId,
198 | tempPlayers[slot.playerId]
199 | ? tempPlayers[slot.playerId].playerName
200 | : "Computer",
201 | slot.teamId,
202 | slot.color,
203 | raceFlagFormatter(slot.raceFlag),
204 | );
205 | }
206 | });
207 |
208 | this.knownPlayerIds = new Set(Object.keys(this.players));
209 | }
210 |
211 | processGameDataBlock(block: GameDataBlock): void {
212 | switch (block.id) {
213 | case 31:
214 | case 30:
215 | this.totalTimeTracker += block.timeIncrement;
216 | this.timeSegmentTracker += block.timeIncrement;
217 | if (this.timeSegmentTracker > this.playerActionTrackInterval) {
218 | Object.values(this.players).forEach((p) =>
219 | p.newActionTrackingSegment(),
220 | );
221 | this.timeSegmentTracker = 0;
222 | }
223 | this.handleTimeSlot(block);
224 | break;
225 | case 0x20:
226 | this.handleChatMessage(block, this.totalTimeTracker);
227 | break;
228 | case 23:
229 | this.leaveEvents.push(block);
230 | break;
231 | }
232 | }
233 |
234 | private getPlayerBySlotId(slotId: number) {
235 | return this.slotToPlayerId.get(slotId);
236 | }
237 |
238 | private numericalChatModeToChatMessageMode(number: number) {
239 | switch (number) {
240 | case 0x00:
241 | return ChatMessageMode.All;
242 | case 0x01:
243 | return ChatMessageMode.Team;
244 | case 0x02:
245 | return ChatMessageMode.Observers;
246 | default:
247 | return ChatMessageMode.Private;
248 | }
249 | }
250 |
251 | handleChatMessage(block: PlayerChatMessageBlock, timeMS: number): void {
252 | const message: ChatMessage = {
253 | playerName: this.players[block.playerId].name,
254 | playerId: block.playerId,
255 | message: block.message,
256 | mode: this.numericalChatModeToChatMessageMode(block.mode),
257 | timeMS,
258 | };
259 | this.chatlog.push(message);
260 | }
261 |
262 | handleTimeSlot(block: TimeslotBlock): void {
263 | this.msElapsed += block.timeIncrement;
264 | block.commandBlocks.forEach((commandBlock): void => {
265 | this.processCommandDataBlock(commandBlock);
266 | });
267 | }
268 |
269 | processCommandDataBlock(block: CommandBlock): void {
270 | if (this.knownPlayerIds.has(String(block.playerId)) === false) {
271 | console.error(
272 | `detected unknown playerId in CommandBlock: ${block.playerId} - time elapsed: ${this.totalTimeTracker}`,
273 | );
274 | return;
275 | }
276 | const currentPlayer = this.players[block.playerId];
277 | currentPlayer.currentTimePlayed = this.totalTimeTracker;
278 | currentPlayer._lastActionWasDeselect = false;
279 |
280 | block.actions.forEach((action: Action): void => {
281 | this.handleActionBlock(action, currentPlayer);
282 | });
283 | }
284 |
285 | handleActionBlock(action: Action, currentPlayer: Player): void {
286 | switch (action.id) {
287 | case 0x10:
288 | if (
289 | objectIdFormatter(action.orderId).value === "tert" ||
290 | objectIdFormatter(action.orderId).value === "tret"
291 | ) {
292 | currentPlayer.handleRetraining(this.totalTimeTracker);
293 | }
294 | currentPlayer.handle0x10(
295 | objectIdFormatter(action.orderId),
296 | this.totalTimeTracker,
297 | );
298 | break;
299 | case 0x11:
300 | currentPlayer.handle0x11(
301 | objectIdFormatter(action.orderId),
302 | this.totalTimeTracker,
303 | );
304 | break;
305 | case 0x12:
306 | currentPlayer.handle0x12(
307 | objectIdFormatter(action.orderId),
308 | this.totalTimeTracker,
309 | );
310 | break;
311 | case 0x13:
312 | currentPlayer.handle0x13();
313 | break;
314 | case 0x14:
315 | currentPlayer.handle0x14(objectIdFormatter(action.orderId1));
316 | break;
317 | case 0x16:
318 | if (action.selectMode === 0x02) {
319 | currentPlayer._lastActionWasDeselect = true;
320 | currentPlayer.handle0x16(action.selectMode, true);
321 | } else {
322 | if (currentPlayer._lastActionWasDeselect === false) {
323 | currentPlayer.handle0x16(action.selectMode, true);
324 | }
325 | currentPlayer._lastActionWasDeselect = false;
326 | }
327 | break;
328 | case 0x17:
329 | case 0x18:
330 | case 0x1c:
331 | case 0x1d:
332 | case 0x1e:
333 | case 0x61:
334 | case 0x65:
335 | case 0x66:
336 | case 0x67:
337 | currentPlayer.handleOther(action);
338 | break;
339 | case 0x51: {
340 | const playerId = this.getPlayerBySlotId(action.slot);
341 | if (playerId) {
342 | const { id, ...actionWithoutId } = action;
343 | currentPlayer.handle0x51({
344 | ...actionWithoutId,
345 | playerId,
346 | playerName: this.players[playerId].name,
347 | });
348 | }
349 | break;
350 | }
351 | case 0x6b:
352 | this.w3mmd.push(action);
353 | break;
354 | }
355 | }
356 |
357 | isObserver(player: Player): boolean {
358 | return (
359 | (player.teamid === 24 && this.info.subheader.version >= 29) ||
360 | (player.teamid === 12 && this.info.subheader.version < 29)
361 | );
362 | }
363 |
364 | determineMatchup(): void {
365 | const teamRaces: { [key: string]: string[] } = {};
366 | Object.values(this.players).forEach((p) => {
367 | if (!this.isObserver(p)) {
368 | teamRaces[p.teamid] = teamRaces[p.teamid] || [];
369 | teamRaces[p.teamid].push(p.raceDetected || p.race);
370 | }
371 | });
372 | this.gametype = Object.values(teamRaces)
373 | .map((e) => e.length)
374 | .sort()
375 | .join("on");
376 | this.matchup = Object.values(teamRaces)
377 | .map((e) => e.sort().join(""))
378 | .sort()
379 | .join("v");
380 | }
381 |
382 | generateID(): void {
383 | const players = Object.values(this.players)
384 | .filter((p) => this.isObserver(p) === false)
385 | .sort((player1, player2) => {
386 | if (player1.id < player2.id) {
387 | return -1;
388 | }
389 | return 1;
390 | })
391 | .reduce((accumulator, player) => {
392 | accumulator += player.name;
393 | return accumulator;
394 | }, "");
395 |
396 | const idBase = this.info.metadata.randomSeed + players + this.meta.gameName;
397 | this.id = createHash("sha256").update(idBase).digest("hex");
398 | }
399 |
400 | cleanup(): void {
401 | this.observers = [];
402 | Object.values(this.players).forEach((p) => {
403 | p.newActionTrackingSegment(this.playerActionTrackInterval);
404 | p.cleanup();
405 | if (this.isObserver(p)) {
406 | this.observers.push(p.name);
407 | delete this.players[p.id];
408 | }
409 | });
410 |
411 | if (
412 | this.info.subheader.version >= 2 &&
413 | Object.prototype.hasOwnProperty.call(this.teams, "24")
414 | ) {
415 | delete this.teams[24];
416 | } else if (Object.prototype.hasOwnProperty.call(this.teams, "12")) {
417 | delete this.teams[12];
418 | }
419 | }
420 |
421 | private getObserverMode(
422 | refereeFlag: boolean,
423 | observerMode: number,
424 | ): ObserverMode {
425 | if ((observerMode === 3 || observerMode === 0) && refereeFlag === true) {
426 | return ObserverMode.REFEREES;
427 | } else if (observerMode === 2) {
428 | return ObserverMode.ON_DEFEAT;
429 | } else if (observerMode === 3) {
430 | return ObserverMode.FULL;
431 | }
432 | return ObserverMode.NONE;
433 | }
434 |
435 | finalize(): ParserOutput {
436 | const settings = {
437 | referees: this.meta.map.referees,
438 | observerMode: this.getObserverMode(
439 | this.meta.map.referees,
440 | this.meta.map.observerMode,
441 | ),
442 | fixedTeams: this.meta.map.fixedTeams,
443 | fullSharedUnitControl: this.meta.map.fullSharedUnitControl,
444 | alwaysVisible: this.meta.map.alwaysVisible,
445 | hideTerrain: this.meta.map.hideTerrain,
446 | mapExplored: this.meta.map.mapExplored,
447 | teamsTogether: this.meta.map.teamsTogether,
448 | randomHero: this.meta.map.randomHero,
449 | randomRaces: this.meta.map.randomRaces,
450 | speed: this.meta.map.speed,
451 | };
452 |
453 | const root = {
454 | id: this.id,
455 | gamename: this.meta.gameName,
456 | randomseed: this.meta.randomSeed,
457 | startSpots: this.meta.startSpotCount,
458 | observers: this.observers,
459 | players: Object.values(this.players).sort(sortPlayers),
460 | matchup: this.matchup,
461 | creator: this.meta.map.creator,
462 | type: this.gametype,
463 | chat: this.chatlog,
464 | apm: {
465 | trackingInterval: this.playerActionTrackInterval,
466 | },
467 | map: {
468 | path: this.meta.map.mapName,
469 | file: convert.mapFilename(this.meta.map.mapName),
470 | checksum: this.meta.map.mapChecksum,
471 | checksumSha1: this.meta.map.mapChecksumSha1,
472 | },
473 | version: convert.gameVersion(this.info.subheader.version),
474 | buildNumber: this.info.subheader.buildNo,
475 | duration: this.info.subheader.replayLengthMS,
476 | expansion: this.info.subheader.gameIdentifier === "PX3W",
477 | settings,
478 | parseTime: Math.round(performance.now() - this.parseStartTime),
479 | winningTeamId: this.winningTeamId,
480 | };
481 | return root;
482 | }
483 | }
484 |
--------------------------------------------------------------------------------
/src/convert.ts:
--------------------------------------------------------------------------------
1 | const playerColor = (color: number): string => {
2 | switch (color) {
3 | case 0:
4 | return "#ff0303";
5 | case 1:
6 | return "#0042ff";
7 | case 2:
8 | return "#1ce6b9";
9 | case 3:
10 | return "#540081";
11 | case 4:
12 | return "#fffc00";
13 | case 5:
14 | return "#fe8a0e";
15 | case 6:
16 | return "#20c000";
17 | case 7:
18 | return "#e55bb0";
19 | case 8:
20 | return "#959697";
21 | case 9:
22 | return "#7ebff1";
23 | case 10:
24 | return "#106246";
25 | case 11:
26 | return "#4a2a04";
27 | case 12:
28 | return "#9b0000";
29 | case 13:
30 | return "#0000c3";
31 | case 14:
32 | return "#00eaff";
33 | case 15:
34 | return "#be00fe";
35 | case 16:
36 | return "#ebcd87";
37 | case 17:
38 | return "#f8a48b";
39 | case 18:
40 | return "#bfff80";
41 | case 19:
42 | return "#dcb9eb";
43 | case 20:
44 | return "#282828";
45 | case 21:
46 | return "#ebf0ff";
47 | case 22:
48 | return "#00781e";
49 | case 23:
50 | return "#a46f33";
51 | default:
52 | return "000000";
53 | }
54 | };
55 |
56 | const gameVersion = (version: number): string => {
57 | if (version === 10030) {
58 | return "1.30.2+";
59 | } else if (version > 10030 && version < 10100) {
60 | const str = String(version);
61 | return `1.${str.substring(str.length - 2, str.length)}`;
62 | } else if (version >= 10100) {
63 | const str = String(version);
64 | return `2.${str.substring(str.length - 2, str.length)}`;
65 | }
66 | return `1.${version}`;
67 | };
68 |
69 | const mapFilenameRegex = /[^\\/]+(\([1-9]+\))?\.(w3x|w3m)/;
70 | const mapFilename = (mapPath: string): string => {
71 | const match = mapFilenameRegex.exec(mapPath);
72 | if (match) {
73 | return match[0];
74 | }
75 | return "";
76 | };
77 |
78 | export default {
79 | playerColor,
80 | gameVersion,
81 | mapFilename,
82 | };
83 |
--------------------------------------------------------------------------------
/src/detectRetraining.ts:
--------------------------------------------------------------------------------
1 | import Player from "./Player";
2 |
3 | const RETRAINING_DETECTION_TIME_RANGE = 60 * 1000;
4 |
5 | export const getRetrainingIndex = (
6 | abilityOrder: Player["heroes"][number]["abilityOrder"],
7 | timeOfTomeOfRetrainingPurchase: number,
8 | ): number => {
9 | if (abilityOrder.length < 3) {
10 | return -1;
11 | }
12 | let candidateForFirstAbilityRelearnedAfterTomeUse = abilityOrder[0];
13 | let candidateForFirstAbilityRelearnedAfterTomeUseIndex = 0;
14 | let abilitiesLearnedInDetectionTimeRange = 0;
15 | for (let i = 1; i < abilityOrder.length; i++) {
16 | if (
17 | abilityOrder[i].time -
18 | candidateForFirstAbilityRelearnedAfterTomeUse.time <
19 | RETRAINING_DETECTION_TIME_RANGE
20 | ) {
21 | abilitiesLearnedInDetectionTimeRange++;
22 | } else {
23 | abilitiesLearnedInDetectionTimeRange = 0;
24 | candidateForFirstAbilityRelearnedAfterTomeUse = abilityOrder[i];
25 | candidateForFirstAbilityRelearnedAfterTomeUseIndex = i;
26 | }
27 | if (
28 | abilitiesLearnedInDetectionTimeRange === 2 &&
29 | candidateForFirstAbilityRelearnedAfterTomeUse.time -
30 | timeOfTomeOfRetrainingPurchase <=
31 | RETRAINING_DETECTION_TIME_RANGE
32 | ) {
33 | return candidateForFirstAbilityRelearnedAfterTomeUseIndex;
34 | }
35 | }
36 | return -1;
37 | };
38 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import W3GReplay from "./W3GReplay";
2 |
3 | import GameDataParser from "./parsers/GameDataParser";
4 | import MetadataParser from "./parsers/MetadataParser";
5 | import RawParser, {
6 | type DataBlock,
7 | getUncompressedData,
8 | } from "./parsers/RawParser";
9 | import ReplayParser from "./parsers/ReplayParser";
10 |
11 | export default W3GReplay;
12 | export {
13 | GameDataParser,
14 | MetadataParser,
15 | RawParser,
16 | ReplayParser,
17 | getUncompressedData,
18 | type DataBlock,
19 | };
20 |
--------------------------------------------------------------------------------
/src/inferHeroAbilityLevelsFromAbilityOrder.ts:
--------------------------------------------------------------------------------
1 | import { Ability, Retraining } from "./Player";
2 |
3 | const ultimates = new Set()
4 | .add("AEtq")
5 | .add("AEme")
6 | .add("AEsf")
7 | .add("AEsv")
8 | .add("AOww")
9 | .add("AOeq")
10 | .add("AOre")
11 | .add("AOvd")
12 | .add("AUan")
13 | .add("AUin")
14 | .add("AUdd")
15 | .add("AUls")
16 | .add("ANef")
17 | .add("ANch")
18 | .add("ANto")
19 | .add("ANdo")
20 | .add("ANst")
21 | .add("ANrg")
22 | .add("ANg1")
23 | .add("ANg2")
24 | .add("ANg3")
25 | .add("ANvc")
26 | .add("ANtm")
27 | .add("AHmt")
28 | .add("AHav")
29 | .add("AHre")
30 | .add("AHpx");
31 |
32 | type HeroAbilities = { [key: string]: number };
33 |
34 | export function inferHeroAbilityLevelsFromAbilityOrder(
35 | abilityOrder: (Ability | Retraining)[],
36 | ): {
37 | finalHeroAbilities: HeroAbilities;
38 | retrainingHistory: { time: number; abilities: HeroAbilities }[];
39 | } {
40 | let abilities: { [key: string]: number } = {};
41 | const retrainings: { time: number; abilities: HeroAbilities }[] = [];
42 | for (const ability of abilityOrder) {
43 | if (ability.type === "ability") {
44 | if (ultimates.has(ability.value) && abilities[ability.value] === 1) {
45 | continue;
46 | }
47 | abilities[ability.value] = abilities[ability.value] || 0;
48 | if (abilities[ability.value] < 3) {
49 | abilities[ability.value]++;
50 | }
51 | }
52 | if (ability.type === "retraining") {
53 | retrainings.push({ time: ability.time, abilities });
54 | abilities = {};
55 | }
56 | }
57 | return { finalHeroAbilities: abilities, retrainingHistory: retrainings };
58 | }
59 |
--------------------------------------------------------------------------------
/src/mappings.ts:
--------------------------------------------------------------------------------
1 | const items: { [key: string]: string } = {
2 | amrc: "i_Amulet of Recall",
3 | ankh: "i_Ankh of Reincarnation",
4 | belv: "i_Boots of Quel'Thalas +6",
5 | bgst: "i_Belt of Giant Strength +6",
6 | bspd: "i_Boots of Speed",
7 | ccmd: "i_Scepter of Mastery",
8 | ciri: "i_Robe of the Magi +6",
9 | ckng: "i_Crown of Kings +5",
10 | clsd: "i_Cloak of Shadows",
11 | crys: "i_Crystal Ball",
12 | desc: "i_Kelen's Dagger of Escape",
13 | gemt: "i_Gem of True Seeing",
14 | gobm: "i_Goblin Land Mines",
15 | gsou: "i_Soul Gem",
16 | guvi: "i_Glyph of Ultravision",
17 | gfor: "i_Glyph of Fortification",
18 | soul: "i_Soul",
19 | mdpb: "i_Medusa Pebble",
20 | rag1: "i_Slippers of Agility +3",
21 | rat3: "i_Claws of Attack +3",
22 | rin1: "i_Mantle of Intelligence +3",
23 | rde1: "i_Ring of Protection +2",
24 | rde2: "i_Ring of Protection +3",
25 | rde3: "i_Ring of Protection +4",
26 | rhth: "i_Khadgar's Gem of Health",
27 | rst1: "i_Gauntlets of Ogre Strength +3",
28 | ofir: "i_Orb of Fire",
29 | ofro: "i_Orb of Frost",
30 | olig: "i_Orb of Lightning",
31 | oli2: "i_Orb of Lightning",
32 | oven: "i_Orb of Venom",
33 | odef: "i_Orb of Darkness",
34 | ocor: "i_Orb of Corruption",
35 | pdiv: "i_Potion of Divinity",
36 | phea: "i_Potion of Healing",
37 | pghe: "i_Potion of Greater Healing",
38 | pinv: "i_Potion of Invisibility",
39 | pgin: "i_Potion of Greater Invisibility",
40 | pman: "i_Potion of Mana",
41 | pgma: "i_Potion of Greater Mana",
42 | pnvu: "i_Potion of Invulnerability",
43 | pnvl: "i_Potion of Lesser Invulnerability",
44 | pres: "i_Potion of Restoration",
45 | pspd: "i_Potion of Speed",
46 | rlif: "i_Ring of Regeneration",
47 | rwiz: "i_Sobi Mask",
48 | sfog: "i_Horn of the Clouds",
49 | shea: "i_Scroll of Healing",
50 | sman: "i_Scroll of Mana",
51 | spro: "i_Scroll of Protection",
52 | sres: "i_Scroll of Restoration",
53 | ssil: "i_Staff of Silence",
54 | stwp: "i_Scroll of Town Portal",
55 | tels: "i_Goblin Night Scope",
56 | tdex: "i_Tome of Agility",
57 | texp: "i_Tome of Experience",
58 | tint: "i_Tome of Intelligence",
59 | tkno: "i_Tome of Power",
60 | tstr: "i_Tome of Strength",
61 | ward: "i_Warsong Battle Drums",
62 | will: "i_Wand of Illusion",
63 | wneg: "i_Wand of Negation",
64 | rdis: "i_Rune of Dispel Magic",
65 | rwat: "i_Rune of the Watcher",
66 | fgrd: "i_Red Drake Egg",
67 | fgrg: "i_Stone Token",
68 | fgdg: "i_Demonic Figurine",
69 | fgfh: "i_Spiked Collar",
70 | fgsk: "i_Book of the Dead",
71 | engs: "i_Enchanted Gemstone",
72 | k3m1: "i_Mooncrystal",
73 | modt: "i_Mask of Death",
74 | sand: "i_Scroll of Animate Dead",
75 | srrc: "i_Scroll of Resurrection",
76 | sror: "i_Scroll of the Beast",
77 | infs: "i_Inferno Stone",
78 | shar: "i_Ice Shard",
79 | wild: "i_Amulet of the Wild",
80 | wswd: "i_Sentry Wards",
81 | whwd: "i_Healing Wards",
82 | wlsd: "i_Wand of Lightning Shield",
83 | wcyc: "i_Wand of the Wind",
84 | rnec: "i_Rod of Necromancy",
85 | pams: "i_Anti-magic Potion",
86 | clfm: "i_Cloak of Flames",
87 | evtl: "i_Talisman of Evasion",
88 | nspi: "i_Necklace of Spell Immunity",
89 | lhst: "i_The Lion Horn of Stormwind",
90 | kpin: "i_Khadgar's Pipe of Insight",
91 | sbch: "i_Scourge Bone Chimes",
92 | afac: "i_Alleria's Flute of Accuracy",
93 | ajen: "i_Ancient Janggo of Endurance",
94 | lgdh: "i_Legion Doom-Horn",
95 | hcun: "i_Hood of Cunning",
96 | mcou: "i_Medallion of Courage",
97 | hval: "i_Helm of Valor",
98 | cnob: "i_Circlet of Nobility",
99 | prvt: "i_Periapt of Vitality",
100 | tgxp: "i_Tome of Greater Experience",
101 | mnst: "i_Mana Stone",
102 | hlst: "i_Health Stone",
103 | tpow: "i_Tome of Knowledge",
104 | tst2: "i_Tome of Strength +2",
105 | tin2: "i_Tome of Intelligence +2",
106 | tdx2: "i_Tome of Agility +2",
107 | rde0: "i_Ring of Protection +1",
108 | rde4: "i_Ring of Protection +5",
109 | rat6: "i_Claws of Attack +6",
110 | rat9: "i_Claws of Attack +9",
111 | ratc: "i_Claws of Attack +12",
112 | ratf: "i_Claws of Attack +15",
113 | manh: "i_Manual of Health",
114 | pmna: "i_Pendant of Mana",
115 | penr: "i_Pendant of Energy",
116 | gcel: "i_Gloves of Haste",
117 | totw: "i_Talisman of the Wild",
118 | phlt: "i_Phat Lewt",
119 | gopr: "i_Glyph of Purification",
120 | ches: "i_Cheese",
121 | mlst: "i_Maul of Strength",
122 | rnsp: "i_Ring of Superiority",
123 | brag: "i_Bracer of Agility",
124 | sksh: "i_Skull Shield",
125 | vddl: "i_Voodoo Doll",
126 | sprn: "i_Spider Ring",
127 | tmmt: "i_Totem of Might",
128 | anfg: "i_Ancient Figurine",
129 | lnrn: "i_Lion's Ring",
130 | iwbr: "i_Ironwood Branch",
131 | jdrn: "i_Jade Ring",
132 | drph: "i_Druid Pouch",
133 | hslv: "i_Healing Salve",
134 | pclr: "i_Clarity Potion",
135 | plcl: "i_Lesser Clarity Potion",
136 | rej1: "i_Minor Replenishment Potion",
137 | rej2: "i_Lesser Replenishment Potion",
138 | rej3: "i_Replenishment Potion",
139 | rej4: "i_Greater Replenishment Potion",
140 | rej5: "i_Lesser Scroll of Replenishment ",
141 | rej6: "i_Greater Scroll of Replenishment ",
142 | sreg: "i_Scroll of Regeneration",
143 | gold: "i_Gold Coins",
144 | lmbr: "i_Bundle of Lumber",
145 | fgun: "i_Flare Gun",
146 | pomn: "i_Potion of Omniscience",
147 | gomn: "i_Glyph of Omniscience",
148 | wneu: "i_Wand of Neutralization",
149 | silk: "i_Spider Silk Broach",
150 | lure: "i_Monster Lure",
151 | skul: "i_Sacrificial Skull",
152 | moon: "i_Moonstone",
153 | brac: "i_Runed Bracers",
154 | vamp: "i_Vampiric Potion",
155 | woms: "i_Wand of Mana Stealing",
156 | tcas: "i_Tiny Castle",
157 | tgrh: "i_Tiny Great Hall",
158 | tsct: "i_Ivory Tower",
159 | wshs: "i_Wand of Shadowsight",
160 | tret: "i_Tome of Retraining",
161 | sneg: "i_Staff of Negation",
162 | stel: "i_Staff of Teleportation",
163 | spre: "i_Staff of Preservation",
164 | mcri: "i_Mechanical Critter",
165 | spsh: "i_Amulet of Spell Shield",
166 | sbok: "i_Spell Book",
167 | ssan: "i_Staff of Sanctuary",
168 | shas: "i_Scroll of Speed",
169 | dust: "i_Dust of Appearance",
170 | oslo: "i_Orb of Slow",
171 | dsum: "i_Diamond of Summoning",
172 | sor1: "i_Shadow Orb +1",
173 | sor2: "i_Shadow Orb +2",
174 | sor3: "i_Shadow Orb +3",
175 | sor4: "i_Shadow Orb +4",
176 | sor5: "i_Shadow Orb +5",
177 | sor6: "i_Shadow Orb +6",
178 | sor7: "i_Shadow Orb +7",
179 | sor8: "i_Shadow Orb +8",
180 | sor9: "i_Shadow Orb +9",
181 | sora: "i_Shadow Orb +10",
182 | sorf: "i_Shadow Orb Fragment",
183 | fwss: "i_Frost Wyrm Skull Shield",
184 | ram1: "i_Ring of the Archmagi",
185 | ram2: "i_Ring of the Archmagi",
186 | ram3: "i_Ring of the Archmagi",
187 | ram4: "i_Ring of the Archmagi",
188 | shtm: "i_Shamanic Totem",
189 | shwd: "i_Shimmerweed",
190 | btst: "i_Battle Standard",
191 | skrt: "i_Skeletal Artifact",
192 | thle: "i_Thunder Lizard Egg",
193 | sclp: "i_Secret Level Powerup",
194 | gldo: "i_Orb of Kil'jaeden",
195 | tbsm: "i_Tiny Blacksmith",
196 | tfar: "i_Tiny Farm",
197 | tlum: "i_Tiny Lumber Mill",
198 | tbar: "i_Tiny Barracks",
199 | tbak: "i_Tiny Altar of Kings",
200 | mgtk: "i_Magic Key Chain",
201 | stre: "i_Staff of Reanimation",
202 | horl: "i_Sacred Relic",
203 | hbth: "i_Helm of Battlethirst",
204 | blba: "i_Bladebane Armor",
205 | rugt: "i_Runed Gauntlets",
206 | frhg: "i_Firehand Gauntlets",
207 | gvsm: "i_Gloves of Spell Mastery",
208 | crdt: "i_Crown of the Deathlord",
209 | arsc: "i_Arcane Scroll",
210 | scul: "i_Scroll of the Unholy Legion",
211 | tmsc: "i_Tome of Sacrifices",
212 | dtsb: "i_Drek'thar's Spellbook",
213 | grsl: "i_Grimoire of Souls",
214 | arsh: "i_Arcanite Shield",
215 | shdt: "i_Shield of the Deathlord",
216 | shhn: "i_Shield of Honor",
217 | shen: "i_Enchanted Shield",
218 | thdm: "i_Thunderlizard Diamond",
219 | stpg: "i_Clockwork Penguin",
220 | shrs: "i_Shimmerglaze Roast",
221 | bfhr: "i_Bloodfeather's Heart",
222 | cosl: "i_Celestial Orb of Souls",
223 | shcw: "i_Shaman Claws",
224 | srbd: "i_Searing Blade",
225 | frgd: "i_Frostguard",
226 | envl: "i_Enchanted Vial",
227 | rump: "i_Rusty Mining Pick",
228 | mort: "i_Mogrin's Report",
229 | srtl: "i_Serathil",
230 | stwa: "i_Sturdy War Axe",
231 | klmm: "i_Killmaim",
232 | rots: "i_Scepter of the Sea",
233 | axas: "i_Ancestral Staff",
234 | mnsf: "i_Mindstaff",
235 | schl: "i_Scepter of Healing",
236 | asbl: "i_Assassin's Blade",
237 | kgal: "i_Keg of Ale",
238 | dphe: "i_Thunder Phoenix Egg",
239 | dkfw: "i_Keg of Thunderwater",
240 | dthb: "i_Thunderbloom Bulb",
241 | };
242 |
243 | const units: { [key: string]: string } = {
244 | hfoo: "u_Footman",
245 | hkni: "u_Knight",
246 | hmpr: "u_Priest",
247 | hmtm: "u_Mortar Team",
248 | hpea: "u_Peasant",
249 | hrif: "u_Rifleman",
250 | hsor: "u_Sorceress",
251 | hmtt: "u_Siege Engine",
252 | hrtt: "u_Siege Engine",
253 | hgry: "u_Gryphon Rider",
254 | hgyr: "u_Flying Machine",
255 | hspt: "u_Spell Breaker",
256 | hdhw: "u_Dragonhawk Rider",
257 | ebal: "u_Glaive Thrower",
258 | echm: "u_Chimaera",
259 | edoc: "u_Druid of the Claw",
260 | edot: "u_Druid of the Talon",
261 | ewsp: "u_Wisp",
262 | esen: "u_Huntress",
263 | earc: "u_Archer",
264 | edry: "u_Dryad",
265 | ehip: "u_Hippogryph",
266 | emtg: "u_Mountain Giant",
267 | efdr: "u_Faerie Dragon",
268 | ocat: "u_Demolisher",
269 | odoc: "u_Troll Witch Doctor",
270 | ogru: "u_Grunt",
271 | ohun: "u_Troll Headhunter/Berserker",
272 | otbk: "u_Troll Headhunter/Berserker",
273 | okod: "u_Kodo Beast",
274 | opeo: "u_Peon",
275 | orai: "u_Raider",
276 | oshm: "u_Shaman",
277 | otau: "u_Tauren",
278 | owyv: "u_Wind Rider",
279 | ospw: "u_Spirit Walker",
280 | ospm: "u_Spirit Walker",
281 | otbr: "u_Troll Batrider",
282 | uaco: "u_Acolyte",
283 | uabo: "u_Abomination",
284 | uban: "u_Banshee",
285 | ucry: "u_Crypt Fiend",
286 | ufro: "u_Frost Wyrm",
287 | ugar: "u_Gargoyle",
288 | ugho: "u_Ghoul",
289 | unec: "u_Necromancer",
290 | umtw: "u_Meatwagon",
291 | ushd: "u_Shade",
292 | uobs: "u_Obsidian Statue",
293 | ubsp: "u_Destroyer",
294 | nskm: "u_Skeletal Marksman",
295 | nskf: "u_Burning Archer",
296 | nws1: "u_Dragon Hawk",
297 | nban: "u_Bandit",
298 | nrog: "u_Rogue",
299 | nenf: "u_Enforcer",
300 | nass: "u_Assassin",
301 | nbdk: "u_Black Drake",
302 | nrdk: "u_Red Dragon Whelp",
303 | nbdr: "u_Black Dragon Whelp",
304 | nrdr: "u_Red Drake",
305 | nbwm: "u_Black Dragon",
306 | nrwm: "u_Red Dragon",
307 | nadr: "u_Blue Dragon",
308 | nadw: "u_Blue Dragon Whelp",
309 | nadk: "u_Blue Drake",
310 | nbzd: "u_Bronze Dragon",
311 | nbzk: "u_Bronze Drake",
312 | nbzw: "u_Bronze Dragon Whelp",
313 | ngrd: "u_Green Dragon",
314 | ngdk: "u_Green Drake",
315 | ngrw: "u_Green Dragon Whelp",
316 | ncea: "u_Centaur Archer",
317 | ncen: "u_Centaur Outrunner",
318 | ncer: "u_Centaur Drudge",
319 | ndth: "u_Dark Troll High Priest",
320 | ndtp: "u_Dark Troll Shadow Priest",
321 | ndtb: "u_Dark Troll Berserker",
322 | ndtw: "u_Dark Troll Warlord",
323 | ndtr: "u_Dark Troll",
324 | ndtt: "u_Dark Troll Trapper",
325 | nfsh: "u_Forest Troll High Priest",
326 | nfsp: "u_Forest Troll Shadow Priest",
327 | nftr: "u_Forest Troll",
328 | nftb: "u_Forest Troll Berserker",
329 | nftt: "u_Forest Troll Trapper",
330 | nftk: "u_Forest Troll Warlord",
331 | ngrk: "u_Mud Golem",
332 | ngir: "u_Goblin Shredder",
333 | nfrs: "u_Furbolg Shaman",
334 | ngna: "u_Gnoll Poacher",
335 | ngns: "u_Gnoll Assassin",
336 | ngno: "u_Gnoll",
337 | ngnb: "u_Gnoll Brute",
338 | ngnw: "u_Gnoll Warden",
339 | ngnv: "u_Gnoll Overseer",
340 | ngsp: "u_Goblin Sapper",
341 | nhrr: "u_Harpy Rogue",
342 | nhrw: "u_Harpy Windwitch",
343 | nits: "u_Ice Troll Berserker",
344 | nitt: "u_Ice Troll Trapper",
345 | nkob: "u_Kobold",
346 | nkog: "u_Kobold Geomancer",
347 | nthl: "u_Thunder Lizard",
348 | nmfs: "u_Murloc Flesheater",
349 | nmrr: "u_Murloc Huntsman",
350 | nowb: "u_Wildkin",
351 | nrzm: "u_Razormane Medicine Man",
352 | nnwa: "u_Nerubian Warrior",
353 | nnwl: "u_Nerubian Webspinner",
354 | nogr: "u_Ogre Warrior",
355 | nogm: "u_Ogre Mauler",
356 | nogl: "u_Ogre Lord",
357 | nomg: "u_Ogre Magi",
358 | nrvs: "u_Frost Revenant",
359 | nslf: "u_Sludge Flinger",
360 | nsts: "u_Satyr Shadowdancer",
361 | nstl: "u_Satyr Soulstealer",
362 | nzep: "u_Goblin Zeppelin",
363 | ntrt: "u_Giant Sea Turtle",
364 | nlds: "u_Makrura Deepseer",
365 | nlsn: "u_Makrura Snapper",
366 | nmsn: "u_Mur'gul Snarecaster",
367 | nscb: "u_Spider Crab Shorecrawler",
368 | nbot: "u_Transport Ship",
369 | nsc2: "u_Spider Crab Limbripper",
370 | nsc3: "u_Spider Crab Behemoth",
371 | nbdm: "u_Blue Dragonspawn Meddler",
372 | nmgw: "u_Magnataur Warrior",
373 | nanb: "u_Barbed Arachnathid",
374 | nanm: "u_Barbed Arachnathid",
375 | nfps: "u_Polar Furbolg Shaman",
376 | nmgv: "u_Magic Vault",
377 | nitb: "u_Icy Treasure Box",
378 | npfl: "u_Fel Beast",
379 | ndrd: "u_Draenei Darkslayer",
380 | ndrm: "u_Draenei Disciple",
381 | nvdw: "u_Voidwalker",
382 | nvdg: "u_Greater Voidwalker",
383 | nnht: "u_Nether Dragon Hatchling",
384 | nndk: "u_Nether Drake",
385 | nndr: "u_Nether Dragon",
386 | };
387 |
388 | const buildings: { [key: string]: string } = {
389 | hhou: "Farm",
390 | halt: "Altar of Kings",
391 | harm: "Workshop",
392 | hars: "Arcane Sanctum",
393 | hbar: "Barracks",
394 | hbla: "Blacksmith",
395 | hgra: "Gryphon Aviary",
396 | hwtw: "Scout Tower",
397 | hvlt: "Arcane Vault",
398 | hlum: "Lumber Mill",
399 | htow: "Town Hall",
400 | hkee: "b_Keep",
401 | hcas: "b_Castle",
402 | hctw: "b_Cannon Tower",
403 | hgtw: "b_Guard Tower",
404 | hatw: "b_Arcane Tower",
405 | etrp: "Ancient Protector",
406 | etol: "Tree of Life",
407 | edob: "Hunter's Hall",
408 | eate: "Altar of Elders",
409 | eden: "Ancient of Wonders",
410 | eaoe: "Ancient of Lore",
411 | eaom: "Ancient of War",
412 | eaow: "Ancient of Wind",
413 | edos: "Chimaera Roost",
414 | emow: "Moon Well",
415 | etoa: "b_Tree of Ages",
416 | etoe: "b_Tree of Eternity",
417 | oalt: "Altar of Storms",
418 | obar: "Barracks",
419 | obea: "Beastiary",
420 | ofor: "War Mill",
421 | ogre: "Great Hall",
422 | osld: "Spirit Lodge",
423 | otrb: "Orc Burrow",
424 | orbr: "Reinforced Orc Burrow",
425 | otto: "Tauren Totem",
426 | ovln: "Voodoo Lounge",
427 | owtw: "Watch Tower",
428 | ofrt: "b_Fortress",
429 | ostr: "b_Stronghold",
430 | uaod: "Altar of Darkness",
431 | unpl: "Necropolis",
432 | usep: "Crypt",
433 | utod: "Temple of the Damned",
434 | utom: "Tomb of Relics",
435 | ugol: "Haunted Gold Mine",
436 | uzig: "Ziggurat",
437 | ubon: "Boneyard",
438 | usap: "Sacrificial Pit",
439 | uslh: "Slaughterhouse",
440 | ugrv: "Graveyard",
441 | unp1: "b_Halls of the Dead",
442 | unp2: "b_Black Citadel",
443 | uzg1: "b_Spirit Tower",
444 | uzg2: "b_Nerubian Tower",
445 | };
446 |
447 | const upgrades: { [key: string]: string } = {
448 | Rhss: "p_Control Magic",
449 | Rhme: "p_Swords",
450 | Rhra: "p_Gunpowder",
451 | Rhar: "p_Plating",
452 | Rhla: "p_Armor",
453 | Rhac: "p_Masonry",
454 | Rhgb: "p_Flying Machine Bombs",
455 | Rhlh: "p_Lumber Harvesting",
456 | Rhde: "p_Defend",
457 | Rhan: "p_Animal War Training",
458 | Rhpt: "p_Priest Training",
459 | Rhst: "p_Sorceress Training",
460 | Rhri: "p_Long Rifles",
461 | Rhse: "p_Magic Sentry",
462 | Rhfl: "p_Flare",
463 | Rhhb: "p_Storm Hammers",
464 | Rhrt: "p_Barrage",
465 | Rhpm: "p_Backpack",
466 | Rhfc: "p_Flak Cannons",
467 | Rhfs: "p_Fragmentation Shards",
468 | Rhcd: "p_Cloud",
469 | Resm: "p_Strength of the Moon",
470 | Resw: "p_Strength of the Wild",
471 | Rema: "p_Moon Armor",
472 | Rerh: "p_Reinforced Hides",
473 | Reuv: "p_Ultravision",
474 | Renb: "p_Nature's Blessing",
475 | Reib: "p_Improved Bows",
476 | Remk: "p_Marksmanship",
477 | Resc: "p_Sentinel",
478 | Remg: "p_Upgrade Moon Glaive",
479 | Redt: "p_Druid of the Talon Training",
480 | Redc: "p_Druid of the Claw Training",
481 | Resi: "p_Abolish Magic",
482 | Reht: "p_Hippogryph Taming",
483 | Recb: "p_Corrosive Breath",
484 | Repb: "p_Vorpal Blades",
485 | Rers: "p_Resistant Skin",
486 | Rehs: "p_Hardened Skin",
487 | Reeb: "p_Mark of the Claw",
488 | Reec: "p_Mark of the Talon",
489 | Rews: "p_Well Spring",
490 | Repm: "p_Backpack",
491 | Roch: "p_Chaos",
492 | Rome: "p_Melee Weapons",
493 | Rora: "p_Ranged Weapons",
494 | Roar: "p_Armor",
495 | Rwdm: "p_War Drums Damage Increase",
496 | Ropg: "p_Pillage",
497 | Robs: "p_Berserker Strength",
498 | Rows: "p_Pulverize",
499 | Roen: "p_Ensnare",
500 | Rovs: "p_Envenomed Spears",
501 | Rowd: "p_Witch Doctor Training",
502 | Rost: "p_Shaman Training",
503 | Rosp: "p_Spiked Barricades",
504 | Rotr: "p_Troll Regeneration",
505 | Rolf: "p_Liquid Fire",
506 | Ropm: "p_Backpack",
507 | Rowt: "p_Spirit Walker Training",
508 | Robk: "p_Berserker Upgrade",
509 | Rorb: "p_Reinforced Defenses",
510 | Robf: "p_Burning Oil",
511 | Rusp: "p_Destroyer Form",
512 | Rume: "p_Unholy Strength",
513 | Rura: "p_Creature Attack",
514 | Ruar: "p_Unholy Armor",
515 | Rucr: "p_Creature Carapace",
516 | Ruac: "p_Cannibalize",
517 | Rugf: "p_Ghoul Frenzy",
518 | Ruwb: "p_Web",
519 | Rusf: "p_Stone Form",
520 | Rune: "p_Necromancer Training",
521 | Ruba: "p_Banshee Training",
522 | Rufb: "p_Freezing Breath",
523 | Rusl: "p_Skeletal Longevity",
524 | Rupc: "p_Disease Cloud",
525 | Rusm: "p_Skeletal Mastery",
526 | Rubu: "p_Burrow",
527 | Ruex: "p_Exhume Corpses",
528 | Rupm: "p_Backpack",
529 | };
530 |
531 | const heroAbilities: { [key: string]: string } = {
532 | AHbz: "a_Archmage:Blizzard",
533 | AHwe: "a_Archmage:Summon Water Elemental",
534 | AHab: "a_Archmage:Brilliance Aura",
535 | AHmt: "a_Archmage:Mass Teleport",
536 | AHtb: "a_Mountain King:Storm Bolt",
537 | AHtc: "a_Mountain King:Thunder Clap",
538 | AHbh: "a_Mountain King:Bash",
539 | AHav: "a_Mountain King:Avatar",
540 | AHhb: "a_Paladin:Holy Light",
541 | AHds: "a_Paladin:Divine Shield",
542 | AHad: "a_Paladin:Devotion Aura",
543 | AHre: "a_Paladin:Resurrection",
544 | AHdr: "a_Blood Mage:Siphon Mana",
545 | AHfs: "a_Blood Mage:Flame Strike",
546 | AHbn: "a_Blood Mage:Banish",
547 | AHpx: "a_Blood Mage:Summon Phoenix",
548 | AEmb: "a_Demon Hunter:Mana Burn",
549 | AEim: "a_Demon Hunter:Immolation",
550 | AEev: "a_Demon Hunter:Evasion",
551 | AEme: "a_Demon Hunter:Metamorphosis",
552 | AEer: "a_Keeper of the Grove:Entangling Roots",
553 | AEfn: "a_Keeper of the Grove:Force of Nature",
554 | AEah: "a_Keeper of the Grove:Thorns Aura",
555 | AEtq: "a_Keeper of the Grove:Tranquility",
556 | AEst: "a_Priestess of the Moon:Scout",
557 | AHfa: "a_Priestess of the Moon:Searing Arrows",
558 | AEar: "a_Priestess of the Moon:Trueshot Aura",
559 | AEsf: "a_Priestess of the Moon:Starfall",
560 | AEbl: "a_Warden:Blink",
561 | AEfk: "a_Warden:Fan of Knives",
562 | AEsh: "a_Warden:Shadow Strike",
563 | AEsv: "a_Warden:Spirit of Vengeance",
564 | AOwk: "a_Blademaster:Wind Walk",
565 | AOmi: "a_Blademaster:Mirror Image",
566 | AOcr: "a_Blademaster:Critical Strike",
567 | AOww: "a_Blademaster:Bladestorm",
568 | AOcl: "a_Far Seer:Chain Lighting",
569 | AOfs: "a_Far Seer:Far Sight",
570 | AOsf: "a_Far Seer:Feral Spirit",
571 | AOeq: "a_Far Seer:Earth Quake",
572 | AOsh: "a_Tauren Chieftain:Shockwave",
573 | AOae: "a_Tauren Chieftain:Endurance Aura",
574 | AOws: "a_Tauren Chieftain:War Stomp",
575 | AOre: "a_Tauren Chieftain:Reincarnation",
576 | AOhw: "a_Shadow Hunter:Healing Wave",
577 | AOhx: "a_Shadow Hunter:Hex",
578 | AOsw: "a_Shadow Hunter:Serpent Ward",
579 | AOvd: "a_Shadow Hunter:Big Bad Voodoo",
580 | AUdc: "a_Death Knight:Death Coil",
581 | AUdp: "a_Death Knight:Death Pact",
582 | AUau: "a_Death Knight:Unholy Aura",
583 | AUan: "a_Death Knight:Animate Dead",
584 | AUcs: "a_Dreadlord:Carrion Swarm",
585 | AUsl: "a_Dreadlord:Sleep",
586 | AUav: "a_Dreadlord:Vampiric Aura",
587 | AUin: "a_Dreadlord:Inferno",
588 | AUfn: "a_Lich:Frost Nova",
589 | AUfa: "a_Lich:Frost Armor",
590 | AUfu: "a_Lich:Frost Armor",
591 | AUdr: "a_Lich:Dark Ritual",
592 | AUdd: "a_Lich:Death and Decay",
593 | AUim: "a_Crypt Lord:Impale",
594 | AUts: "a_Crypt Lord:Spiked Carapace",
595 | AUcb: "a_Crypt Lord:Carrion Beetles",
596 | AUls: "a_Crypt Lord:Locust Swarm",
597 | ANbf: "a_Pandaren Brewmaster:Breath of Fire",
598 | ANdb: "a_Pandaren Brewmaster:Drunken Brawler",
599 | ANdh: "a_Pandaren Brewmaster:Drunken Haze",
600 | ANef: "a_Pandaren Brewmaster:Storm Earth and Fire",
601 | ANdr: "a_Dark Ranger:Life Drain",
602 | ANsi: "a_Dark Ranger:Silence",
603 | ANba: "a_Dark Ranger:Black Arrow",
604 | ANch: "a_Dark Ranger:Charm",
605 | ANms: "a_Naga Sea Witch:Mana Shield",
606 | ANfa: "a_Naga Sea Witch:Frost Arrows",
607 | ANfl: "a_Naga Sea Witch:Forked Lightning",
608 | ANto: "a_Naga Sea Witch:Tornado",
609 | ANrf: "a_Pit Lord:Rain of Fire",
610 | ANca: "a_Pit Lord:Cleaving Attack",
611 | ANht: "a_Pit Lord:Howl of Terror",
612 | ANdo: "a_Pit Lord:Doom",
613 | ANsg: "a_Beastmaster:Summon Bear",
614 | ANsq: "a_Beastmaster:Summon Quilbeast",
615 | ANsw: "a_Beastmaster:Summon Hawk",
616 | ANst: "a_Beastmaster:Stampede",
617 | ANeg: "a_Goblin Tinker:Engineering Upgrade",
618 | ANcs: "a_Goblin Tinker:Cluster Rockets",
619 | ANc1: "a_Goblin Tinker:Cluster Rockets",
620 | ANc2: "a_Goblin Tinker:Cluster Rockets",
621 | ANc3: "a_Goblin Tinker:Cluster Rockets",
622 | ANsy: "a_Goblin Tinker:Pocket Factory",
623 | ANs1: "a_Goblin Tinker:Pocket Factory",
624 | ANs2: "a_Goblin Tinker:Pocket Factory",
625 | ANs3: "a_Goblin Tinker:Pocket Factory",
626 | ANrg: "a_Goblin Tinker:Robo-Goblin",
627 | ANg1: "a_Goblin Tinker:Robo-Goblin",
628 | ANg2: "a_Goblin Tinker:Robo-Goblin",
629 | ANg3: "a_Goblin Tinker:Robo-Goblin",
630 | ANic: "a_Firelord:Incinerate",
631 | ANia: "a_Firelord:Incinerate",
632 | ANso: "a_Firelord:Soul Burn",
633 | ANlm: "a_Firelord:Summon Lava Spawn",
634 | ANvc: "a_Firelord:Volcano",
635 | ANhs: "a_Goblin Alchemist:Healing Spray",
636 | ANab: "a_Goblin Alchemist:Acid Bomb",
637 | ANcr: "a_Goblin Alchemist:Chemical Rage",
638 | ANtm: "a_Goblin Alchemist:Transmute",
639 | };
640 |
641 | const abilityToHero: { [key: string]: string } = {
642 | AHbz: "Hamg",
643 | AHwe: "Hamg",
644 | AHab: "Hamg",
645 | AHmt: "Hamg",
646 | AHtb: "Hmkg",
647 | AHtc: "Hmkg",
648 | AHbh: "Hmkg",
649 | AHav: "Hmkg",
650 | AHhb: "Hpal",
651 | AHds: "Hpal",
652 | AHad: "Hpal",
653 | AHre: "Hpal",
654 | AHdr: "Hblm",
655 | AHfs: "Hblm",
656 | AHbn: "Hblm",
657 | AHpx: "Hblm",
658 | AEmb: "Edem",
659 | AEim: "Edem",
660 | AEev: "Edem",
661 | AEme: "Edem",
662 | AEer: "Ekee",
663 | AEfn: "Ekee",
664 | AEah: "Ekee",
665 | AEtq: "Ekee",
666 | AEst: "Emoo",
667 | AHfa: "Emoo",
668 | AEar: "Emoo",
669 | AEsf: "Emoo",
670 | AEbl: "Ewar",
671 | AEfk: "Ewar",
672 | AEsh: "Ewar",
673 | AEsv: "Ewar",
674 | AOwk: "Obla",
675 | AOmi: "Obla",
676 | AOcr: "Obla",
677 | AOww: "Obla",
678 | AOcl: "Ofar",
679 | AOfs: "Ofar",
680 | AOsf: "Ofar",
681 | AOeq: "Ofar",
682 | AOsh: "Otch",
683 | AOae: "Otch",
684 | AOws: "Otch",
685 | AOre: "Otch",
686 | AOhw: "Oshd",
687 | AOhx: "Oshd",
688 | AOsw: "Oshd",
689 | AOvd: "Oshd",
690 | AUdc: "Udea",
691 | AUdp: "Udea",
692 | AUau: "Udea",
693 | AUan: "Udea",
694 | AUcs: "Udre",
695 | AUsl: "Udre",
696 | AUav: "Udre",
697 | AUin: "Udre",
698 | AUfn: "Ulic",
699 | AUfa: "Ulic",
700 | AUfu: "Ulic",
701 | AUdr: "Ulic",
702 | AUdd: "Ulic",
703 | AUim: "Ucrl",
704 | AUts: "Ucrl",
705 | AUcb: "Ucrl",
706 | AUls: "Ucrl",
707 | ANbf: "Npbm",
708 | ANdb: "Npbm",
709 | ANdh: "Npbm",
710 | ANef: "Npbm",
711 | ANdr: "Nbrn",
712 | ANsi: "Nbrn",
713 | ANba: "Nbrn",
714 | ANch: "Nbrn",
715 | ANms: "Nngs",
716 | ANfa: "Nngs",
717 | ANfl: "Nngs",
718 | ANto: "Nngs",
719 | ANrf: "Nplh",
720 | ANca: "Nplh",
721 | ANht: "Nplh",
722 | ANdo: "Nplh",
723 | ANsg: "Nbst",
724 | ANsq: "Nbst",
725 | ANsw: "Nbst",
726 | ANst: "Nbst",
727 | ANeg: "Ntin",
728 | ANcs: "Ntin",
729 | ANc1: "Ntin",
730 | ANc2: "Ntin",
731 | ANc3: "Ntin",
732 | ANsy: "Ntin",
733 | ANs1: "Ntin",
734 | ANs2: "Ntin",
735 | ANs3: "Ntin",
736 | ANrg: "Ntin",
737 | ANg1: "Ntin",
738 | ANg2: "Ntin",
739 | ANg3: "Ntin",
740 | ANic: "Nfir",
741 | ANia: "Nfir",
742 | ANso: "Nfir",
743 | ANlm: "Nfir",
744 | ANvc: "Nfir",
745 | ANhs: "Nalc",
746 | ANab: "Nalc",
747 | ANcr: "Nalc",
748 | ANtm: "Nalc",
749 | };
750 |
751 | const itemIds = Object.keys(items);
752 |
753 | export {
754 | items,
755 | units,
756 | buildings,
757 | upgrades,
758 | heroAbilities,
759 | abilityToHero,
760 | itemIds,
761 | };
762 |
--------------------------------------------------------------------------------
/src/parsers/ActionParser.ts:
--------------------------------------------------------------------------------
1 | import StatefulBufferParser from "./StatefulBufferParser";
2 |
3 | type NetTag = [number, number];
4 | type Vec2 = [number, number];
5 |
6 | type UnitBuildingAbilityActionNoParams = {
7 | id: 0x10;
8 | abilityFlags: number;
9 | orderId: number[];
10 | };
11 |
12 | type UnitBuildingAbilityActionTargetPosition = {
13 | id: 0x11;
14 | abilityFlags: number;
15 | orderId: number[];
16 | target: Vec2;
17 | };
18 |
19 | type UnitBuildingAbilityActionTargetPositionTargetObjectId = {
20 | id: 0x12;
21 | abilityFlags: number;
22 | orderId: number[];
23 | target: Vec2;
24 | object: NetTag;
25 | };
26 |
27 | type GiveItemToUnitAciton = {
28 | id: 0x13;
29 | abilityFlags: number;
30 | orderId: number[];
31 | target: Vec2;
32 | unit: NetTag;
33 | item: NetTag;
34 | };
35 |
36 | type UnitBuildingAbilityActionTwoTargetPositions = {
37 | id: 0x14;
38 | abilityFlags: number;
39 | orderId1: number[];
40 | targetA: Vec2;
41 | orderId2: number[];
42 | flags: number;
43 | category: number;
44 | owner: number;
45 | targetB: Vec2;
46 | };
47 |
48 | type UnitBuildingAbilityActionTargetPositionTargetObjectIdItemObjectId = {
49 | id: 0x15;
50 | abilityFlags: number;
51 | orderId1: number[];
52 | targetA: Vec2;
53 | orderId2: number[];
54 | flags: number;
55 | category: number;
56 | owner: number;
57 | targetB: Vec2;
58 | object: NetTag;
59 | };
60 |
61 | type ChangeSelectionAction = {
62 | id: 0x16;
63 | selectMode: number;
64 | numberUnits: number;
65 | units: NetTag[];
66 | };
67 |
68 | type AssignGroupHotkeyAction = {
69 | id: 0x17;
70 | groupNumber: number;
71 | numberUnits: number;
72 | units: NetTag[];
73 | };
74 |
75 | type SelectGroupHotkeyAction = {
76 | id: 0x18;
77 | groupNumber: number;
78 | };
79 |
80 | type SelectSubgroupAction = {
81 | id: 0x19;
82 | itemId: number[];
83 | object: NetTag;
84 | };
85 |
86 | type PreSubselectionAction = {
87 | id: 0x1a;
88 | };
89 |
90 | type SelectUnitAction = {
91 | id: 0x1b;
92 | object: NetTag;
93 | };
94 |
95 | type SelectGroundItemAction = {
96 | id: 0x1c;
97 | item: NetTag;
98 | };
99 |
100 | type CancelHeroRevival = {
101 | id: 0x1d;
102 | hero: NetTag;
103 | };
104 |
105 | type RemoveUnitFromBuildingQueue = {
106 | id: 0x1e | 0x1f;
107 | slotNumber: number;
108 | itemId: number[];
109 | };
110 |
111 | export type TransferResourcesAction = {
112 | id: 0x51;
113 | slot: number;
114 | gold: number;
115 | lumber: number;
116 | };
117 |
118 | type ESCPressedAction = {
119 | id: 0x61;
120 | };
121 |
122 | type ChooseHeroSkillSubmenu = {
123 | id: 0x66;
124 | };
125 |
126 | type EnterBuildingSubmenu = {
127 | id: 0x67;
128 | };
129 |
130 | type Cache = {
131 | filename: string;
132 | missionKey: string;
133 | key: string;
134 | };
135 |
136 | type BlzCacheStoreIntAction = {
137 | id: 0x6b;
138 | cache: Cache;
139 | value: number;
140 | };
141 |
142 | export type W3MMDAction = BlzCacheStoreIntAction;
143 |
144 | type BlzCacheStoreRealAction = {
145 | id: 0x6c;
146 | cache: Cache;
147 | value: number;
148 | };
149 |
150 | type BlzCacheStoreBooleanAction = {
151 | id: 0x6d;
152 | cache: Cache;
153 | value: number;
154 | };
155 |
156 | export type Item = {
157 | itemId: number[];
158 | charges: number;
159 | flags: number;
160 | };
161 |
162 | export type Ability = {
163 | id: number[];
164 | level: number;
165 | };
166 |
167 | type HeroData = {
168 | xp: number;
169 | level: number;
170 | skillPoints: number;
171 | properNameId: number;
172 | str: number;
173 | strBonus: number; //float
174 | agi: number;
175 | speedMod: number; //float
176 | cooldownMod: number; //float
177 | agiBonus: number; //float
178 | intel: number;
179 | intBonus: number; //float
180 | heroAbils: Ability[];
181 | maxLife: number; //float
182 | maxMana: number; //float
183 | // >= 6030
184 | sight: number; //float
185 | damage: number[]; //int[]
186 | defense: number; //float
187 | // >= 6031
188 | controlGroups: number; //int16
189 | };
190 |
191 | export type Unit = {
192 | unitId: number[];
193 | items: Item[];
194 | heroData: HeroData;
195 | };
196 |
197 | type BlzCacheStoreUnitAction = {
198 | id: 0x6e;
199 | cache: Cache;
200 | value: Unit;
201 | };
202 |
203 | type BlzCacheClearIntAction = {
204 | id: 0x70;
205 | cache: Cache;
206 | };
207 |
208 | type BlzCacheClearRealAction = {
209 | id: 0x71;
210 | cache: Cache;
211 | };
212 |
213 | type BlzCacheClearBooleanAction = {
214 | id: 0x72;
215 | cache: Cache;
216 | };
217 |
218 | type BlzCacheClearUnitAction = {
219 | id: 0x73;
220 | cache: Cache;
221 | };
222 |
223 | type ArrowKeyAction = {
224 | id: 0x75;
225 | arrowKey: number;
226 | };
227 |
228 | type BlzSyncAction = {
229 | id: 0x78;
230 | identifier: string;
231 | value: string;
232 | };
233 |
234 | type CommandFrameAction = {
235 | id: 0x79;
236 | eventId: number;
237 | val: number;
238 | text: string;
239 | };
240 |
241 | type MouseAction = {
242 | id: 0x76;
243 | eventId: number;
244 | pos: Vec2;
245 | button: number;
246 | };
247 |
248 | type W3APIAction = {
249 | id: 0x77;
250 | commandId: number;
251 | data: number;
252 | buffer: string;
253 | };
254 |
255 | type SetGameSpeedAction = {
256 | id: 0x3;
257 | gameSpeed: number;
258 | };
259 |
260 | type TrackableHitAction = {
261 | id: 0x64;
262 | object: NetTag;
263 | };
264 |
265 | type TrackableTrackAction = {
266 | id: 0x65;
267 | object: NetTag;
268 | };
269 |
270 | type AllyPingAction = {
271 | id: 0x68;
272 | pos: Vec2;
273 | duration: number;
274 | };
275 |
276 | export type Action =
277 | | UnitBuildingAbilityActionNoParams
278 | | UnitBuildingAbilityActionTargetPositionTargetObjectId
279 | | GiveItemToUnitAciton
280 | | UnitBuildingAbilityActionTwoTargetPositions
281 | | PreSubselectionAction
282 | | ChangeSelectionAction
283 | | AssignGroupHotkeyAction
284 | | SelectGroupHotkeyAction
285 | | SelectSubgroupAction
286 | | SelectGroundItemAction
287 | | CancelHeroRevival
288 | | RemoveUnitFromBuildingQueue
289 | | BlzCacheStoreIntAction
290 | | BlzCacheStoreRealAction
291 | | BlzCacheStoreBooleanAction
292 | | BlzCacheStoreUnitAction
293 | | BlzCacheClearIntAction
294 | | BlzCacheClearRealAction
295 | | BlzCacheClearBooleanAction
296 | | BlzCacheClearUnitAction
297 | | ESCPressedAction
298 | | ChooseHeroSkillSubmenu
299 | | EnterBuildingSubmenu
300 | | TransferResourcesAction
301 | | UnitBuildingAbilityActionTargetPosition
302 | | BlzSyncAction
303 | | CommandFrameAction
304 | | MouseAction
305 | | W3APIAction
306 | | SetGameSpeedAction
307 | | UnitBuildingAbilityActionTargetPositionTargetObjectIdItemObjectId
308 | | SelectUnitAction
309 | | TrackableHitAction
310 | | TrackableTrackAction
311 | | AllyPingAction
312 | | ArrowKeyAction;
313 |
314 | export default class ActionParser extends StatefulBufferParser {
315 | parse(input: Buffer, post_202: boolean = false): Action[] {
316 | this.initialize(input);
317 | const actions: Action[] = [];
318 | while (this.getOffset() < input.length) {
319 | try {
320 | const actionId = this.readUInt8();
321 | const action = this.parseAction(actionId, post_202);
322 | if (action !== null) actions.push(action);
323 | } catch (ex) {
324 | console.error(ex);
325 | break;
326 | }
327 | }
328 | return actions;
329 | }
330 |
331 | private oldActionId: number = 999999;
332 | private parseAction(
333 | actionId: number,
334 | isPost202ReplayFormat: boolean = false,
335 | ): Action | null {
336 | try {
337 | if (isPost202ReplayFormat && actionId > 0x77) {
338 | actionId++;
339 | }
340 | switch (actionId) {
341 | // no action 0x00
342 | case 0x1:
343 | this.skip(1);
344 | break;
345 | case 0x2:
346 | break;
347 | case 0x3:
348 | const gameSpeed = this.readUInt8();
349 | return { id: actionId, gameSpeed };
350 | case 0x4:
351 | case 0x5:
352 | break;
353 | case 0x6:
354 | this.readZeroTermString("utf-8");
355 | this.readZeroTermString("utf-8");
356 | this.readUInt8();
357 | break;
358 | case 0x7:
359 | this.skip(4);
360 | break;
361 | // no actions 0x08 - 0x0f
362 | case 0x10: {
363 | const abilityFlags = this.readUInt16LE();
364 | const orderId = this.readFourCC();
365 | this.skip(8); // AgentTag
366 | return { id: actionId, abilityFlags, orderId };
367 | }
368 | case 0x11: {
369 | const abilityFlags = this.readUInt16LE();
370 | const orderId = this.readFourCC();
371 | this.skip(8); // AgentTag
372 | const target = this.readVec2();
373 | return { id: actionId, abilityFlags, orderId, target };
374 | }
375 | case 0x12: {
376 | const abilityFlags = this.readUInt16LE();
377 | const orderId = this.readFourCC();
378 | this.skip(8); // AgentTag
379 | const target = this.readVec2();
380 | const object = this.readNetTag();
381 | return {
382 | id: actionId,
383 | abilityFlags,
384 | orderId,
385 | target,
386 | object,
387 | };
388 | }
389 | case 0x13: {
390 | const abilityFlags = this.readUInt16LE();
391 | const orderId = this.readFourCC();
392 | this.skip(8); // AgentTag
393 | const target = this.readVec2();
394 | const unit = this.readNetTag();
395 | const item = this.readNetTag();
396 | return {
397 | id: actionId,
398 | abilityFlags,
399 | orderId,
400 | target,
401 | unit,
402 | item,
403 | };
404 | }
405 | case 0x14: {
406 | const abilityFlags = this.readUInt16LE();
407 | const orderId1 = this.readFourCC();
408 | this.skip(8); // AgentTag
409 | const targetA = this.readVec2();
410 | const orderId2 = this.readFourCC();
411 | const flags = this.readUInt32LE();
412 | const category = this.readUInt32LE();
413 | const owner = this.readUInt8();
414 | const targetB = this.readVec2();
415 | return {
416 | id: actionId,
417 | abilityFlags,
418 | orderId1,
419 | targetA,
420 | orderId2,
421 | flags,
422 | category,
423 | owner,
424 | targetB,
425 | };
426 | }
427 | case 0x15: {
428 | const abilityFlags = this.readUInt16LE();
429 | const orderId1 = this.readFourCC();
430 | this.skip(8); // AgentTag
431 | const targetA = this.readVec2();
432 | const orderId2 = this.readFourCC();
433 | const flags = this.readUInt32LE();
434 | const category = this.readUInt32LE();
435 | const owner = this.readUInt8();
436 | const targetB = this.readVec2();
437 | const object = this.readNetTag();
438 | return {
439 | id: actionId,
440 | abilityFlags,
441 | orderId1,
442 | targetA,
443 | orderId2,
444 | flags,
445 | category,
446 | owner,
447 | targetB,
448 | object,
449 | };
450 | }
451 | case 0x16: {
452 | const selectMode = this.readUInt8();
453 | const numberUnits = this.readUInt16LE();
454 | const actions = this.readSelectionUnits(numberUnits);
455 | return { id: actionId, selectMode, numberUnits, units: actions };
456 | }
457 | case 0x17: {
458 | const groupNumber = this.readUInt8();
459 | const numberUnits = this.readUInt16LE();
460 | const actions = this.readSelectionUnits(numberUnits);
461 | return { id: actionId, groupNumber, numberUnits, units: actions };
462 | }
463 | case 0x18: {
464 | const groupNumber = this.readUInt8();
465 | this.skip(1);
466 | return { id: actionId, groupNumber };
467 | }
468 | case 0x19: {
469 | const itemId = this.readFourCC();
470 | const object = this.readNetTag();
471 | return { id: actionId, itemId, object };
472 | }
473 | case 0x1a: {
474 | return { id: actionId };
475 | }
476 | case 0x1b: {
477 | this.skip(1);
478 | const object = this.readNetTag();
479 | return { id: actionId, object };
480 | }
481 | case 0x1c: {
482 | this.skip(1);
483 | const item = this.readNetTag();
484 | return { id: actionId, item };
485 | }
486 | case 0x1d: {
487 | const hero = this.readNetTag();
488 | return { id: actionId, hero };
489 | }
490 | case 0x1e:
491 | // couldn't find action 0x1f in reference
492 | case 0x1f: {
493 | const slotNumber = this.readUInt8();
494 | const itemId = this.readFourCC();
495 | return { id: actionId, slotNumber, itemId };
496 | }
497 | // 0x20 to 0x4f are cheat actions
498 | case 0x20:
499 | break;
500 | case 0x21:
501 | this.skip(8);
502 | break;
503 | case 0x22:
504 | case 0x23:
505 | case 0x24:
506 | case 0x25:
507 | case 0x26:
508 | break;
509 | case 0x27:
510 | case 0x28:
511 | this.skip(5);
512 | break;
513 | case 0x29:
514 | case 0x2a:
515 | case 0x2b:
516 | case 0x2c:
517 | break;
518 | case 0x2d:
519 | this.skip(5);
520 | break;
521 | case 0x2e:
522 | this.skip(4);
523 | break;
524 | case 0x2f:
525 | break;
526 | case 0x50:
527 | this.readUInt8(); // slotNumber
528 | this.readUInt32LE(); // flags
529 | return null;
530 | case 0x51:
531 | const slot = this.readUInt8();
532 | const gold = this.readUInt32LE();
533 | const lumber = this.readUInt32LE();
534 | return { id: actionId, slot, gold, lumber };
535 | // no actions 0x52 - 0x5f
536 | case 0x60:
537 | this.skip(8);
538 | this.readZeroTermString("utf-8");
539 | return null;
540 | case 0x61:
541 | return { id: actionId };
542 | case 0x62:
543 | this.skip(12);
544 | return null;
545 | case 0x63:
546 | this.skip(8);
547 | return null;
548 | case 0x64:
549 | case 0x65: {
550 | const object = this.readNetTag();
551 | return { id: actionId, object };
552 | }
553 | case 0x66:
554 | case 0x67:
555 | return {
556 | id: actionId,
557 | };
558 | case 0x68: {
559 | const pos = this.readVec2();
560 | const duration = this.readFloatLE();
561 | return { id: actionId, pos, duration };
562 | }
563 | case 0x69:
564 | case 0x6a: {
565 | this.skip(16);
566 | break;
567 | }
568 | case 0x6b: {
569 | const cache = this.readCacheDesc();
570 | const value = this.readUInt32LE();
571 | return { id: actionId, cache, value };
572 | }
573 | case 0x6c: {
574 | const cache = this.readCacheDesc();
575 | const value = this.readFloatLE();
576 | return { id: actionId, cache, value };
577 | }
578 | case 0x6d: {
579 | const cache = this.readCacheDesc();
580 | const value = this.readUInt8();
581 | return { id: actionId, cache, value };
582 | }
583 | case 0x6e: {
584 | const cache = this.readCacheDesc();
585 | const value = this.readCacheUnit();
586 | return { id: actionId, cache, value };
587 | }
588 | // no action 0x6f
589 | case 0x70: {
590 | const cache = this.readCacheDesc();
591 | return { id: actionId, cache };
592 | }
593 | case 0x71: {
594 | const cache = this.readCacheDesc();
595 | return { id: actionId, cache };
596 | }
597 | case 0x72: {
598 | const cache = this.readCacheDesc();
599 | return { id: actionId, cache };
600 | }
601 | case 0x73: {
602 | const cache = this.readCacheDesc();
603 | return { id: actionId, cache };
604 | }
605 | // no action 0x74
606 | case 0x75: {
607 | const arrowKey = this.readUInt8();
608 | return { id: actionId, arrowKey };
609 | break;
610 | }
611 | case 0x76: {
612 | const eventId = this.readUInt8();
613 | const pos = this.readVec2();
614 | const button = this.readUInt8();
615 | return { id: actionId, eventId, pos, button };
616 | }
617 | case 0x77: {
618 | const commandId = this.readUInt32LE();
619 | const data = this.readUInt32LE();
620 | const buffLen = this.readUInt32LE();
621 | const buffer = this.readStringOfLength(buffLen, "utf-8");
622 | return { id: actionId, commandId, data, buffer };
623 | }
624 | case 0x78: {
625 | const identifier = this.readZeroTermString("utf8");
626 | const value = this.readZeroTermString("utf8");
627 | this.skip(4);
628 | return { id: actionId, identifier, value };
629 | }
630 | case 0x79: {
631 | this.skip(8);
632 | const eventId = this.readUInt32LE();
633 | const val = this.readFloatLE();
634 | const text = this.readZeroTermString("utf-8");
635 | return { id: actionId, eventId, val, text };
636 | }
637 | case 0x7a:
638 | this.skip(20);
639 | break;
640 | case 0x7b:
641 | this.skip(16);
642 | break;
643 | // no actions 0x7c - 0x80
644 | // 0x81 - 0x85 are replay actions
645 | // no actions 0x86 - 0x9f
646 | // 0xa0 and 0xa1 are cheats
647 | case 0xa0:
648 | this.skip(14);
649 | break;
650 | case 0xa1:
651 | this.skip(9);
652 | default:
653 | console.error(
654 | "unknown action id ",
655 | actionId,
656 | " after ",
657 | this.oldActionId,
658 | " at offset ",
659 | this.getOffset() - 1,
660 | );
661 | return null;
662 | }
663 | return null;
664 | } finally {
665 | this.oldActionId = actionId;
666 | }
667 | }
668 |
669 | private readSelectionUnits(length: number): NetTag[] {
670 | const v: NetTag[] = [];
671 | for (let i = 0; i < length; i++) {
672 | v.push(this.readNetTag());
673 | }
674 | return v;
675 | }
676 |
677 | private readFourCC(): number[] {
678 | const fourCC = [
679 | this.readUInt8(),
680 | this.readUInt8(),
681 | this.readUInt8(),
682 | this.readUInt8(),
683 | ];
684 | return fourCC;
685 | }
686 |
687 | private readCacheDesc(): Cache {
688 | const filename = this.readZeroTermString("utf-8");
689 | const missionKey = this.readZeroTermString("utf-8");
690 | const key = this.readZeroTermString("utf-8");
691 | return { filename, missionKey, key };
692 | }
693 |
694 | private readCacheItem(): Item {
695 | const itemId = this.readFourCC();
696 | const charges = this.readUInt32LE();
697 | const flags = this.readUInt32LE();
698 | return { itemId, charges, flags };
699 | }
700 |
701 | private readAbility(): Ability {
702 | const id = this.readFourCC();
703 | const level = this.readUInt32LE();
704 | return { id, level };
705 | }
706 |
707 | private readCacheHeroData(): HeroData {
708 | const xp = this.readUInt32LE();
709 | const level = this.readUInt32LE();
710 | const skillPoints = this.readUInt32LE();
711 | const properNameId = this.readUInt32LE();
712 | const str = this.readUInt32LE();
713 | const strBonus = this.readFloatLE();
714 | const agi = this.readUInt32LE();
715 | const speedMod = this.readFloatLE();
716 | const cooldownMod = this.readFloatLE();
717 | const agiBonus = this.readFloatLE();
718 | const intel = this.readUInt32LE();
719 | const intBonus = this.readFloatLE();
720 | const heroAbilCount = this.readUInt32LE();
721 | const heroAbils: Ability[] = [];
722 | for (let i = 0; i < heroAbilCount; i++) {
723 | heroAbils.push(this.readAbility());
724 | }
725 | const maxLife = this.readFloatLE();
726 | const maxMana = this.readFloatLE();
727 | const sight = this.readFloatLE();
728 | const damageCount = this.readUInt32LE();
729 | const damage: number[] = [];
730 | for (let i = 0; i < damageCount; i++) {
731 | damage.push(this.readUInt32LE());
732 | }
733 | const defense = this.readFloatLE();
734 | const controlGroups = this.readUInt16LE();
735 | return {
736 | xp,
737 | level,
738 | skillPoints,
739 | properNameId,
740 | str,
741 | strBonus,
742 | agi,
743 | speedMod,
744 | cooldownMod,
745 | agiBonus,
746 | intel,
747 | intBonus,
748 | heroAbils,
749 | maxLife,
750 | maxMana,
751 | sight,
752 | damage,
753 | defense,
754 | controlGroups,
755 | };
756 | }
757 |
758 | private readCacheUnit(): Unit {
759 | const unitId = this.readFourCC();
760 | const itemsCount = this.readUInt32LE();
761 | const items: Item[] = [];
762 | for (let i = 0; i < itemsCount; i++) {
763 | items.push(this.readCacheItem());
764 | }
765 | const heroData = this.readCacheHeroData();
766 | return { unitId, items, heroData };
767 | }
768 |
769 | private readNetTag(): NetTag {
770 | const tag1 = this.readUInt32LE();
771 | const tag2 = this.readUInt32LE();
772 | return [tag1, tag2];
773 | }
774 |
775 | private readVec2(): Vec2 {
776 | const x = this.readFloatLE();
777 | const y = this.readFloatLE();
778 | return [x, y];
779 | }
780 | }
781 |
--------------------------------------------------------------------------------
/src/parsers/GameDataParser.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from "node:events";
2 | import StatefulBufferParser from "./StatefulBufferParser";
3 | import ActionParser from "./ActionParser";
4 | import { Action } from "./ActionParser";
5 |
6 | const setImmediatePromise = () =>
7 | new Promise((resolve) => setImmediate(resolve));
8 |
9 | export type LeaveGameBlock = {
10 | id: 0x17;
11 | playerId: number;
12 | reason: string;
13 | result: string;
14 | };
15 |
16 | export type TimeslotBlock = {
17 | id: 0x1f | 0x1e;
18 | timeIncrement: number;
19 | commandBlocks: CommandBlock[];
20 | };
21 |
22 | export type CommandBlock = {
23 | playerId: number;
24 | actions: Action[];
25 | };
26 |
27 | export type PlayerChatMessageBlock = {
28 | id: 0x20;
29 | playerId: number;
30 | mode: number;
31 | message: string;
32 | };
33 |
34 | export type GameDataBlock =
35 | | LeaveGameBlock
36 | | TimeslotBlock
37 | | PlayerChatMessageBlock;
38 |
39 | export default class GameDataParser extends EventEmitter {
40 | private actionParser: ActionParser;
41 | private parser: StatefulBufferParser;
42 | private isPost202ReplayFormat: boolean = false;
43 |
44 | constructor() {
45 | super();
46 | this.actionParser = new ActionParser();
47 | this.parser = new StatefulBufferParser();
48 | }
49 |
50 | async parse(
51 | data: Buffer,
52 | isPost202ReplayFormat: boolean = false,
53 | ): Promise {
54 | this.isPost202ReplayFormat = isPost202ReplayFormat;
55 | this.parser.initialize(data);
56 | while (this.parser.offset < data.length) {
57 | const block = this.parseBlock();
58 | if (block !== null) {
59 | this.emit("gamedatablock", block);
60 | }
61 | await setImmediatePromise();
62 | }
63 | }
64 |
65 | private parseBlock(): GameDataBlock | null {
66 | const id = this.parser.readUInt8();
67 | switch (id) {
68 | case 0x17:
69 | return this.parseLeaveGameBlock();
70 | case 0x1a:
71 | this.parser.skip(4);
72 | break;
73 | case 0x1b:
74 | this.parser.skip(4);
75 | break;
76 | case 0x1c:
77 | this.parser.skip(4);
78 | break;
79 | case 0x1f:
80 | return this.parseTimeslotBlock();
81 | case 0x1e:
82 | return this.parseTimeslotBlock();
83 | case 0x20:
84 | return this.parseChatMessage();
85 | case 0x22:
86 | this.parseUnknown0x22();
87 | break;
88 | case 0x23:
89 | this.parser.skip(10);
90 | break;
91 | case 0x2f:
92 | this.parser.skip(8);
93 | break;
94 | }
95 | return null;
96 | }
97 |
98 | private parseUnknown0x22(): void {
99 | const length = this.parser.readUInt8();
100 | this.parser.skip(length);
101 | }
102 |
103 | private parseChatMessage(): PlayerChatMessageBlock {
104 | const playerId = this.parser.readUInt8();
105 | this.parser.readUInt16LE(); // byteCount
106 | const flags = this.parser.readUInt8();
107 | let mode = 0;
108 | if (flags === 0x20) {
109 | mode = this.parser.readUInt32LE();
110 | }
111 | const message = this.parser.readZeroTermString("utf-8");
112 | return {
113 | id: 0x20,
114 | playerId,
115 | mode,
116 | message,
117 | };
118 | }
119 |
120 | private parseLeaveGameBlock(): LeaveGameBlock {
121 | const reason = this.parser.readStringOfLength(4, "hex");
122 | const playerId = this.parser.readUInt8();
123 | const result = this.parser.readStringOfLength(4, "hex");
124 | this.parser.skip(4);
125 | return {
126 | id: 0x17,
127 | reason,
128 | playerId,
129 | result,
130 | };
131 | }
132 |
133 | private parseTimeslotBlock(): TimeslotBlock {
134 | const byteCount = this.parser.readUInt16LE();
135 | const timeIncrement = this.parser.readUInt16LE();
136 | const actionBlockLastOffset = this.parser.offset + byteCount - 2;
137 | const commandBlocks: CommandBlock[] = [];
138 | while (this.parser.offset < actionBlockLastOffset) {
139 | const commandBlock: Partial = {};
140 | commandBlock.playerId = this.parser.readUInt8();
141 | const actionBlockLength = this.parser.readUInt16LE();
142 | const actions = this.parser.buffer.subarray(
143 | this.parser.offset,
144 | this.parser.offset + actionBlockLength,
145 | );
146 | commandBlock.actions = this.actionParser.parse(
147 | actions,
148 | this.isPost202ReplayFormat,
149 | );
150 | this.parser.skip(actionBlockLength);
151 | commandBlocks.push(commandBlock as CommandBlock);
152 | }
153 | return { id: 0x1f, timeIncrement, commandBlocks };
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/parsers/MetadataParser.ts:
--------------------------------------------------------------------------------
1 | import { DataBlock, getUncompressedData } from "./RawParser";
2 | import StatefulBufferParser from "./StatefulBufferParser";
3 | import { Type, Field } from "protobufjs";
4 |
5 | const protoPlayer = new Type("ReforgedPlayerData")
6 | .add(new Field("playerId", 1, "uint32"))
7 | .add(new Field("battleTag", 2, "string"))
8 | .add(new Field("clan", 3, "string"))
9 | .add(new Field("portrait", 4, "string"))
10 | .add(new Field("team", 5, "uint32"))
11 | .add(new Field("unknown", 6, "string"));
12 |
13 | type SkinData = {
14 | unitId: number;
15 | skinId: number;
16 | skinName: string;
17 | };
18 |
19 | const protoSkinData = new Type("SkinData")
20 | .add(new Field("unitId", 1, "uint32"))
21 | .add(new Field("skinId", 2, "uint32"))
22 | .add(new Field("skinName", 3, "string"));
23 |
24 | const protoSkin = new Type("ReforgedSkinData")
25 | .add(protoSkinData)
26 | .add(new Field("playerId", 1, "uint32"))
27 | .add(new Field("skins", 2, "SkinData", "repeated"));
28 |
29 | export type ReplayMetadata = {
30 | gameData: Buffer;
31 | map: MapMetadata;
32 | playerCount: number;
33 | gameType: string;
34 | localeHash: string;
35 | playerRecords: PlayerRecord[];
36 | slotRecords: SlotRecord[];
37 | reforgedPlayerMetadata: ReforgedPlayerMetadata[];
38 | randomSeed: number;
39 | selectMode: string;
40 | gameName: string;
41 | startSpotCount: number;
42 | isPost202ReplayFormat: boolean;
43 | };
44 |
45 | type PlayerRecord = {
46 | playerId: number;
47 | playerName: string;
48 | };
49 |
50 | type SlotRecord = {
51 | playerId: number;
52 | // 0-100, -1 for bots and embedded maps
53 | downloadProgress: number;
54 | slotStatus: number;
55 | computerFlag: number;
56 | teamId: number;
57 | color: number;
58 | raceFlag: number;
59 | aiStrength: number;
60 | handicapFlag: number;
61 | };
62 |
63 | type ReforgedPlayerMetadata = {
64 | playerId: number;
65 | name: string;
66 | clan: string;
67 | skins: SkinData[];
68 | };
69 |
70 | type MapMetadata = {
71 | speed: number;
72 | hideTerrain: boolean;
73 | mapExplored: boolean;
74 | alwaysVisible: boolean;
75 | default: boolean;
76 | observerMode: number;
77 | teamsTogether: boolean;
78 | fixedTeams: boolean;
79 | fullSharedUnitControl: boolean;
80 | randomHero: boolean;
81 | randomRaces: boolean;
82 | referees: boolean;
83 | mapChecksum: string;
84 | mapChecksumSha1: string;
85 | mapName: string;
86 | creator: string;
87 | };
88 |
89 | export default class MetadataParser extends StatefulBufferParser {
90 | private mapmetaParser: StatefulBufferParser = new StatefulBufferParser();
91 |
92 | private isPost202ReplayFormat: boolean = false;
93 | async parse(blocks: DataBlock[]): Promise {
94 | return this.parseData(await getUncompressedData(blocks));
95 | }
96 |
97 | public async parseData(data: Buffer): Promise {
98 | this.initialize(data);
99 | this.skip(5);
100 | const playerRecords = [];
101 | playerRecords.push(this.parseHostRecord());
102 | const gameName = this.readZeroTermString("utf-8");
103 | this.readZeroTermString("utf-8"); // privateString
104 | const encodedString = this.readZeroTermString("hex");
105 | const mapMetadata = this.parseEncodedMapMetaString(
106 | this.decodeGameMetaString(encodedString),
107 | );
108 | const playerCount = this.readUInt32LE();
109 | const gameType = this.readStringOfLength(4, "hex");
110 | const localeHash = this.readStringOfLength(4, "hex");
111 | const playerListFinal = playerRecords.concat(
112 | playerRecords,
113 | this.parsePlayerList(),
114 | );
115 | let reforgedPlayerMetadata: ReforgedPlayerMetadata[] = [];
116 | if (this.peekUInt8() !== 25) {
117 | reforgedPlayerMetadata = this.parseReforgedPlayerMetadata();
118 | }
119 | if (this.readUInt8() !== 25) {
120 | console.error(
121 | "Unknown chunk detected!",
122 | this.buffer.subarray(this.getOffset() - 1),
123 | );
124 | }
125 | const remainingBytes = this.readUInt16LE();
126 | const slotRecordCount = this.readUInt8();
127 | // remaining bytes are: slotRecordCount(1), slots(9*count), seed(4), mode(1), spots(1)
128 | if (remainingBytes !== 1 + slotRecordCount * 9 + 6) {
129 | console.error(
130 | `Remaining bytes (${remainingBytes}) do not match expected bytes (${1 + slotRecordCount * 9 + 6})`,
131 | );
132 | }
133 | const slotRecords = this.parseSlotRecords(slotRecordCount);
134 | const randomSeed = this.readUInt32LE();
135 | const selectMode = this.readStringOfLength(1, "hex");
136 | const startSpotCount = this.readUInt8();
137 | return {
138 | gameData: this.buffer.subarray(this.getOffset()),
139 | map: mapMetadata,
140 | playerCount,
141 | gameType,
142 | localeHash,
143 | playerRecords: playerListFinal,
144 | slotRecords,
145 | reforgedPlayerMetadata,
146 | randomSeed,
147 | selectMode,
148 | gameName,
149 | startSpotCount,
150 | isPost202ReplayFormat: this.isPost202ReplayFormat,
151 | };
152 | }
153 |
154 | private parseSlotRecords(count: number): SlotRecord[] {
155 | const slots: SlotRecord[] = [];
156 | for (let i = 0; i < count; i++) {
157 | const record: Partial = {};
158 | record.playerId = this.readUInt8();
159 | record.downloadProgress = this.readUInt8();
160 | record.slotStatus = this.readUInt8();
161 | record.computerFlag = this.readUInt8();
162 | record.teamId = this.readUInt8();
163 | record.color = this.readUInt8();
164 | record.raceFlag = this.readUInt8();
165 | record.aiStrength = this.readUInt8();
166 | record.handicapFlag = this.readUInt8();
167 | slots.push(record as SlotRecord);
168 | }
169 | return slots;
170 | }
171 |
172 | private parseReforgedPlayerMetadata(): ReforgedPlayerMetadata[] {
173 | const result: ReforgedPlayerMetadata[] = [];
174 | const skinSet: Map = new Map();
175 | while (this.peekUInt8() === 0x38 || this.peekUInt8() === 0x39) {
176 | if (this.readUInt8() === 0x38) {
177 | this.isPost202ReplayFormat = true;
178 | }
179 | const subtype = this.readUInt8();
180 | const followingBytes = this.readUInt32LE();
181 | const data = this.buffer.subarray(
182 | this.offset,
183 | this.offset + followingBytes,
184 | );
185 | if (subtype === 0x3) {
186 | const decoded = protoPlayer.decode(data) as unknown as {
187 | playerId: number;
188 | battleTag: string;
189 | clan: string;
190 | };
191 | if (decoded.clan === undefined) {
192 | decoded.clan = "";
193 | }
194 | result.push({
195 | playerId: decoded.playerId,
196 | name: decoded.battleTag,
197 | clan: decoded.clan,
198 | skins: [],
199 | });
200 | } else if (subtype === 0x4) {
201 | const decoded = protoSkin.decode(data) as unknown as {
202 | playerId: number;
203 | skins: SkinData[];
204 | };
205 | if (decoded.skins !== undefined) {
206 | skinSet.set(decoded.playerId, decoded.skins);
207 | }
208 | }
209 | this.skip(followingBytes);
210 | }
211 | for (const player of result) {
212 | if (skinSet.has(player.playerId)) {
213 | player.skins = skinSet.get(player.playerId)!;
214 | }
215 | }
216 | return result;
217 | }
218 |
219 | private parseEncodedMapMetaString(buffer: Buffer): MapMetadata {
220 | const parser = this.mapmetaParser;
221 | parser.initialize(buffer);
222 |
223 | const speed = parser.readUInt8();
224 | const secondByte = parser.readUInt8();
225 | const thirdByte = parser.readUInt8();
226 | const fourthByte = parser.readUInt8();
227 | parser.skip(5);
228 | const checksum = parser.readStringOfLength(4, "hex");
229 | parser.skip(0);
230 | const mapName = parser.readZeroTermString("utf-8");
231 | const creator = parser.readZeroTermString("utf-8");
232 | parser.skip(1);
233 | const checksumSha1 = parser.readStringOfLength(20, "hex");
234 | return {
235 | speed,
236 | hideTerrain: !!(secondByte & 0b00000001),
237 | mapExplored: !!(secondByte & 0b00000010),
238 | alwaysVisible: !!(secondByte & 0b00000100),
239 | default: !!(secondByte & 0b00001000),
240 | observerMode: (secondByte & 0b00110000) >>> 4,
241 | teamsTogether: !!(secondByte & 0b01000000),
242 | fixedTeams: !!(thirdByte & 0b00000110),
243 | fullSharedUnitControl: !!(fourthByte & 0b00000001),
244 | randomHero: !!(fourthByte & 0b00000010),
245 | randomRaces: !!(fourthByte & 0b00000100),
246 | referees: !!(fourthByte & 0b01000000),
247 | mapName: mapName,
248 | creator: creator,
249 | mapChecksum: checksum,
250 | mapChecksumSha1: checksumSha1,
251 | };
252 | }
253 |
254 | private parsePlayerList() {
255 | const list: PlayerRecord[] = [];
256 | while (this.readUInt8() === 22) {
257 | list.push(this.parseHostRecord());
258 | this.skip(4);
259 | }
260 | this.skip(-1);
261 | return list;
262 | }
263 |
264 | private parseHostRecord(): PlayerRecord {
265 | const playerId = this.readUInt8();
266 | const playerName = this.readZeroTermString("utf-8");
267 | const addData = this.readUInt8();
268 | this.skip(addData);
269 | return { playerId, playerName };
270 | }
271 |
272 | private decodeGameMetaString(str: string): Buffer {
273 | const hexRepresentation = Buffer.from(str, "hex");
274 | const decoded = Buffer.alloc(hexRepresentation.length);
275 | let mask = 0;
276 | let dpos = 0;
277 |
278 | for (let i = 0; i < hexRepresentation.length; i++) {
279 | if (i % 8 === 0) {
280 | mask = hexRepresentation[i];
281 | } else {
282 | if ((mask & (0x1 << i % 8)) === 0) {
283 | decoded.writeUInt8(hexRepresentation[i] - 1, dpos++);
284 | } else {
285 | decoded.writeUInt8(hexRepresentation[i], dpos++);
286 | }
287 | }
288 | }
289 | return decoded;
290 | }
291 | }
292 |
--------------------------------------------------------------------------------
/src/parsers/RawParser.ts:
--------------------------------------------------------------------------------
1 | import StatefulBufferParser from "./StatefulBufferParser";
2 | import { inflate, constants } from "node:zlib";
3 |
4 | export type Header = {
5 | compressedSize: number;
6 | headerVersion: string;
7 | decompressedSize: number;
8 | compressedDataBlockCount: number;
9 | };
10 |
11 | export type SubHeader = {
12 | gameIdentifier: string;
13 | version: number;
14 | buildNo: number;
15 | replayLengthMS: number;
16 | };
17 |
18 | type RawReplayData = {
19 | header: Header;
20 | subheader: SubHeader;
21 | blocks: DataBlock[];
22 | };
23 |
24 | export type DataBlock = {
25 | blockSize: number;
26 | blockDecompressedSize: number;
27 | blockContent: Buffer;
28 | };
29 |
30 | const inflatePromise = (buffer: Buffer, options = {}): Promise =>
31 | new Promise((resolve, reject) => {
32 | inflate(buffer, options, (err, result) => {
33 | if (err !== null) {
34 | reject(err);
35 | }
36 | resolve(result);
37 | });
38 | });
39 |
40 | export async function getUncompressedData(
41 | blocks: DataBlock[],
42 | ): Promise {
43 | const buffs: Buffer[] = [];
44 | for (const block of blocks) {
45 | const block2 = await inflatePromise(block.blockContent, {
46 | finishFlush: constants.Z_SYNC_FLUSH,
47 | });
48 | if (block2.byteLength > 0 && block.blockContent.byteLength > 0) {
49 | buffs.push(block2);
50 | }
51 | }
52 | return Buffer.concat(buffs);
53 | }
54 |
55 | export default class CustomReplayParser extends StatefulBufferParser {
56 | private header: Header;
57 | private subheader: SubHeader;
58 |
59 | constructor() {
60 | super();
61 | }
62 |
63 | public async parse(input: Buffer): Promise {
64 | this.initialize(input);
65 | this.header = this.parseHeader();
66 | this.subheader = this.parseSubheader();
67 | return {
68 | header: this.header,
69 | subheader: this.subheader,
70 | blocks: this.parseBlocks(),
71 | };
72 | }
73 |
74 | private parseBlocks(): DataBlock[] {
75 | const blocks: DataBlock[] = [];
76 |
77 | while (this.getOffset() < this.buffer.length) {
78 | const block = this.parseBlock();
79 | if (block.blockDecompressedSize === 8192) {
80 | blocks.push(block);
81 | }
82 | }
83 | return blocks;
84 | }
85 |
86 | private parseBlock(): DataBlock {
87 | const isReforged = this.subheader.buildNo < 6089 ? false : true;
88 | const blockSize = this.readUInt16LE();
89 |
90 | this.skip(isReforged ? 2 : 0);
91 | const blockDecompressedSize = this.readUInt16LE();
92 |
93 | this.skip(isReforged ? 6 : 4);
94 | const blockContent = this.buffer.subarray(
95 | this.getOffset(),
96 | this.getOffset() + blockSize,
97 | );
98 | this.skip(blockSize);
99 | return {
100 | blockSize,
101 | blockDecompressedSize,
102 | blockContent,
103 | };
104 | }
105 |
106 | private parseSubheader(): SubHeader {
107 | const gameIdentifier = this.readStringOfLength(4, "utf-8");
108 | const version = this.readUInt32LE();
109 | const buildNo = this.readUInt16LE();
110 | this.skip(2);
111 | const replayLengthMS = this.readUInt32LE();
112 | this.skip(4);
113 | return {
114 | gameIdentifier,
115 | version,
116 | buildNo,
117 | replayLengthMS,
118 | };
119 | }
120 |
121 | private findParseStartOffset(): number {
122 | return this.buffer.indexOf("Warcraft III recorded game");
123 | }
124 |
125 | private parseHeader(): Header {
126 | const offset = this.findParseStartOffset();
127 | this.setOffset(offset);
128 | this.readZeroTermString("ascii");
129 | this.skip(4);
130 | const compressedSize = this.readUInt32LE();
131 | const headerVersion = this.readStringOfLength(4, "hex");
132 | const decompressedSize = this.readUInt32LE();
133 | const compressedDataBlockCount = this.readUInt32LE();
134 |
135 | return {
136 | decompressedSize,
137 | headerVersion,
138 | compressedDataBlockCount,
139 | compressedSize,
140 | };
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/parsers/ReplayParser.ts:
--------------------------------------------------------------------------------
1 | import RawParser, { Header, SubHeader } from "./RawParser";
2 | import MetadataParser, { ReplayMetadata } from "./MetadataParser";
3 | import GameDataParser, { GameDataBlock } from "./GameDataParser";
4 | import { EventEmitter } from "node:events";
5 |
6 | export type ParserOutput = {
7 | header: Header;
8 | subheader: SubHeader;
9 | metadata: ReplayMetadata;
10 | };
11 |
12 | export type BasicReplayInformation = ParserOutput;
13 | export interface ReplayParserEvents {
14 | on(event: "gamedatablock", listener: (block: GameDataBlock) => void): this;
15 | on(
16 | event: "basic_replay_information",
17 | listener: (data: BasicReplayInformation) => void,
18 | ): this;
19 | }
20 |
21 | export default class ReplayParser
22 | extends EventEmitter
23 | implements ReplayParserEvents
24 | {
25 | private rawParser: RawParser = new RawParser();
26 | private metadataParser: MetadataParser = new MetadataParser();
27 | private gameDataParser: GameDataParser = new GameDataParser();
28 |
29 | constructor() {
30 | super();
31 | this.gameDataParser.on("gamedatablock", (block: GameDataBlock) =>
32 | this.emit("gamedatablock", block),
33 | );
34 | }
35 |
36 | async parse(input: Buffer): Promise {
37 | const rawParserResult = await this.rawParser.parse(input);
38 | const metadataParserResult = await this.metadataParser.parse(
39 | rawParserResult.blocks,
40 | );
41 | const result: ParserOutput = {
42 | header: rawParserResult.header,
43 | subheader: rawParserResult.subheader,
44 | metadata: metadataParserResult,
45 | };
46 | this.emit("basic_replay_information", {
47 | header: rawParserResult.header,
48 | subheader: rawParserResult.subheader,
49 | metadata: metadataParserResult,
50 | });
51 | await this.gameDataParser.parse(
52 | metadataParserResult.gameData,
53 | metadataParserResult.isPost202ReplayFormat,
54 | );
55 |
56 | return result;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/parsers/StatefulBufferParser.ts:
--------------------------------------------------------------------------------
1 | const readZeroTermString = (
2 | input: Buffer,
3 | startAt = 0,
4 | encoding: BufferEncoding,
5 | ): { value: string; posDifference: number } => {
6 | let pos = startAt;
7 | while (input.readInt8(pos) !== 0) {
8 | pos++;
9 | }
10 | return {
11 | value: input.subarray(startAt, pos).toString(encoding),
12 | posDifference: pos - startAt + 1,
13 | };
14 | };
15 |
16 | const readStringOfLength = (
17 | input: Buffer,
18 | length: number,
19 | startAt = 0,
20 | encoding: BufferEncoding = "utf-8",
21 | ): string => {
22 | return input.subarray(startAt, startAt + length).toString(encoding);
23 | };
24 | export default class StatefulBufferParser {
25 | buffer: Buffer;
26 | offset = 0;
27 |
28 | public initialize(buffer: Buffer): void {
29 | this.buffer = buffer;
30 | this.offset = 0;
31 | }
32 |
33 | public readStringOfLength(length: number, encoding: BufferEncoding): string {
34 | const result = readStringOfLength(
35 | this.buffer,
36 | length,
37 | this.offset,
38 | encoding,
39 | );
40 | this.offset += length;
41 | return result;
42 | }
43 |
44 | public setOffset(offset: number): void {
45 | this.offset = offset;
46 | }
47 |
48 | public getOffset(): number {
49 | return this.offset;
50 | }
51 |
52 | public skip(byteCount: number): void {
53 | this.offset += byteCount;
54 | }
55 |
56 | public readZeroTermString(encoding: BufferEncoding): string {
57 | const result = readZeroTermString(this.buffer, this.offset, encoding);
58 | this.offset += result.posDifference;
59 | return result.value;
60 | }
61 |
62 | public readUInt32LE(): number {
63 | const val = this.buffer.readUInt32LE(this.offset);
64 | this.offset += 4;
65 | return val;
66 | }
67 |
68 | public readUInt16LE(): number {
69 | const val = this.buffer.readUInt16LE(this.offset);
70 | this.offset += 2;
71 | return val;
72 | }
73 |
74 | public readUInt8(): number {
75 | const val = this.buffer.readUInt8(this.offset);
76 | this.offset += 1;
77 | return val;
78 | }
79 |
80 | public peekUInt8(): number {
81 | const val = this.buffer.readUInt8(this.offset);
82 | return val;
83 | }
84 |
85 | public readFloatLE(): number {
86 | const val = this.buffer.readFloatLE(this.offset);
87 | this.offset += 4;
88 | return val;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/parsers/formatters.ts:
--------------------------------------------------------------------------------
1 | import { Race } from "../types";
2 |
3 | type ObjectIdStringencoded = {
4 | type: "stringencoded";
5 | value: string;
6 | };
7 |
8 | type ObjectIdAlphanumeric = {
9 | type: "alphanumeric";
10 | value: number[];
11 | };
12 |
13 | export type ItemId = ObjectIdAlphanumeric | ObjectIdStringencoded;
14 |
15 | export const objectIdFormatter = (arr: number[]): ItemId => {
16 | if (arr[3] >= 0x41 && arr[3] <= 0x7a) {
17 | return {
18 | type: "stringencoded",
19 | value: arr
20 | .map((e) => String.fromCharCode(e as number))
21 | .reverse()
22 | .join(""),
23 | };
24 | }
25 | return { type: "alphanumeric", value: arr };
26 | };
27 |
28 | export const raceFlagFormatter = (flag: number): Race => {
29 | switch (flag) {
30 | case 0x01:
31 | case 0x41:
32 | return Race.Human;
33 | case 0x02:
34 | case 0x42:
35 | return Race.Orc;
36 | case 0x04:
37 | case 0x44:
38 | return Race.NightElf;
39 | case 0x08:
40 | case 0x48:
41 | return Race.Undead;
42 | case 0x20:
43 | case 0x60:
44 | return Race.Random;
45 | }
46 | return Race.Random;
47 | };
48 |
49 | export const chatModeFormatter = (flag: number): string => {
50 | switch (flag) {
51 | case 0x00:
52 | return "ALL";
53 | case 0x01:
54 | return "ALLY";
55 | case 0x02:
56 | return "OBS";
57 | }
58 |
59 | if (flag >= 3 && flag <= 27) {
60 | return `PRIVATE${flag}`;
61 | }
62 |
63 | return "UNKNOWN";
64 | };
65 |
--------------------------------------------------------------------------------
/src/sort.ts:
--------------------------------------------------------------------------------
1 | import Player from "./Player";
2 |
3 | type SortablePlayerProps = Pick;
4 |
5 | export const sortPlayers = (
6 | player1: SortablePlayerProps,
7 | player2: SortablePlayerProps,
8 | ): number => {
9 | if (player2.teamid > player1.teamid) return -1;
10 | if (player2.teamid < player1.teamid) return 1;
11 |
12 | if (player2.id > player1.id) return -1;
13 | if (player2.id < player1.id) return 1;
14 |
15 | return 0;
16 | };
17 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import Player from "./Player";
2 | import { ChatMessage, ObserverMode } from "./W3GReplay";
3 |
4 | export enum Race {
5 | Human = "H",
6 | NightElf = "N",
7 | Orc = "O",
8 | Undead = "U",
9 | Random = "R",
10 | }
11 |
12 | interface AlphaNumericItemId {
13 | type: "alphanumeric";
14 | value: number[];
15 | }
16 |
17 | interface StringEncodedItemId {
18 | type: "stringencoded";
19 | value: string;
20 | }
21 |
22 | export type ItemID = AlphaNumericItemId | StringEncodedItemId;
23 | export interface ParserOutput {
24 | id: string;
25 | gamename: string;
26 | randomseed: number;
27 | startSpots: number;
28 | observers: string[];
29 | players: Player[];
30 | matchup: string;
31 | creator: string;
32 | type: string;
33 | chat: ChatMessage[];
34 | apm: {
35 | trackingInterval: number;
36 | };
37 | map: {
38 | path: string;
39 | file: string;
40 | checksum: string;
41 | checksumSha1: string;
42 | };
43 | buildNumber: number;
44 | version: string;
45 | duration: number;
46 | expansion: boolean;
47 | parseTime: number;
48 | winningTeamId: number;
49 | settings: {
50 | observerMode: ObserverMode;
51 | fixedTeams: boolean;
52 | fullSharedUnitControl: boolean;
53 | alwaysVisible: boolean;
54 | hideTerrain: boolean;
55 | mapExplored: boolean;
56 | teamsTogether: boolean;
57 | randomHero: boolean;
58 | randomRaces: boolean;
59 | speed: number;
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/test/convert.test.ts:
--------------------------------------------------------------------------------
1 | import convert from "../src/convert";
2 |
3 | describe("mapFilename", () => {
4 | it("returns mapfilename if path separator is \\ ", () => {
5 | expect(convert.mapFilename("Maps\\test\\somemap.w3x")).toBe("somemap.w3x");
6 | });
7 |
8 | it("returns mapfilename if path separator is // ", () => {
9 | expect(convert.mapFilename("Maps//test//somemap.w3x")).toBe("somemap.w3x");
10 | });
11 |
12 | it("returns mapfilename if path separator is // and then \\ ", () => {
13 | expect(convert.mapFilename("Maps//test\\somemap.w3x")).toBe("somemap.w3x");
14 | });
15 |
16 | it("returns mapfilename if path separator is \\ and then // ", () => {
17 | expect(convert.mapFilename("Maps\\test//somemap.w3x")).toBe("somemap.w3x");
18 | });
19 |
20 | it("returns mapfilename if path separator is \\ and then // repeated multiple times ", () => {
21 | expect(convert.mapFilename("Maps\\test\\test2//test3//somemap.w3x")).toBe(
22 | "somemap.w3x",
23 | );
24 | });
25 |
26 | it("returns mapfilename if path separator is // and then \\ repeated multiple times ", () => {
27 | expect(convert.mapFilename("Maps//test//test2\\test3\\somemap.w3x")).toBe(
28 | "somemap.w3x",
29 | );
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/test/formatters.test.ts:
--------------------------------------------------------------------------------
1 | import { chatModeFormatter } from "../src/parsers/formatters";
2 | describe("chatModeFormatter", () => {
3 | it("correctly handles 0 as ALL", () => {
4 | expect(chatModeFormatter(0)).toBe("ALL");
5 | });
6 | it("correctly handles 1 as ALLY", () => {
7 | expect(chatModeFormatter(1)).toBe("ALLY");
8 | });
9 | it("correctly handles 2 as OBS", () => {
10 | expect(chatModeFormatter(2)).toBe("OBS");
11 | });
12 | for (let i = 0; i < 24; i++) {
13 | it(`correctly handles private message to slot ${i}`, () => {
14 | expect(chatModeFormatter(3 + i)).toBe(`PRIVATE${i + 3}`);
15 | });
16 | }
17 | });
18 |
--------------------------------------------------------------------------------
/test/getRetrainingIndex.test.ts:
--------------------------------------------------------------------------------
1 | import { getRetrainingIndex } from "../src/detectRetraining";
2 | import Player from "../src/Player";
3 |
4 | it("detects a retraining successfully", () => {
5 | const retrainedAbilityOrder: Player["heroes"][number]["abilityOrder"] = [
6 | {
7 | type: "ability",
8 | time: 125743,
9 | value: "AHwe",
10 | },
11 | {
12 | type: "ability",
13 | time: 167347,
14 | value: "AHab",
15 | },
16 | {
17 | type: "ability",
18 | time: 230430,
19 | value: "AHwe",
20 | },
21 | {
22 | type: "ability",
23 | time: 543939,
24 | value: "AHab",
25 | },
26 | {
27 | type: "ability",
28 | time: 818999,
29 | value: "AHwe",
30 | },
31 | {
32 | type: "ability",
33 | time: 1211048,
34 | value: "AHmt",
35 | },
36 | {
37 | type: "ability",
38 | time: 1443410,
39 | value: "AHbz",
40 | },
41 | {
42 | type: "ability",
43 | time: 1443563,
44 | value: "AHbz",
45 | },
46 | {
47 | type: "ability",
48 | time: 1443685,
49 | value: "AHbz",
50 | },
51 | {
52 | type: "ability",
53 | time: 1444048,
54 | value: "AHab",
55 | },
56 | {
57 | type: "ability",
58 | time: 1444231,
59 | value: "AHab",
60 | },
61 | {
62 | type: "ability",
63 | time: 1444384,
64 | value: "AHmt",
65 | },
66 | ];
67 | expect(getRetrainingIndex(retrainedAbilityOrder, 1399843)).toBe(6);
68 | });
69 |
70 | it("returns -1 for test case 1", () => {
71 | const expectedFalse: Player["heroes"][number]["abilityOrder"] = [
72 | {
73 | type: "ability",
74 | time: 141559,
75 | value: "ANsg",
76 | },
77 | {
78 | type: "ability",
79 | time: 372693,
80 | value: "ANsg",
81 | },
82 | {
83 | type: "ability",
84 | time: 523758,
85 | value: "ANsw",
86 | },
87 | {
88 | type: "ability",
89 | time: 523879,
90 | value: "ANsw",
91 | },
92 | {
93 | type: "ability",
94 | time: 701002,
95 | value: "ANsg",
96 | },
97 | {
98 | type: "ability",
99 | time: 1080754,
100 | value: "ANst",
101 | },
102 | {
103 | type: "ability",
104 | time: 1468279,
105 | value: "ANsw",
106 | },
107 | ];
108 | expect(getRetrainingIndex(expectedFalse, 1399843)).toBe(-1);
109 | });
110 |
111 | it("returns -1 for test case 2", () => {
112 | const expectedFalse: Player["heroes"][number]["abilityOrder"] = [
113 | {
114 | type: "ability",
115 | time: 631947,
116 | value: "ANbf",
117 | },
118 | {
119 | type: "ability",
120 | time: 689454,
121 | value: "ANdh",
122 | },
123 | {
124 | type: "ability",
125 | time: 910900,
126 | value: "ANbf",
127 | },
128 | {
129 | type: "ability",
130 | time: 1069108,
131 | value: "ANdb",
132 | },
133 | {
134 | type: "ability",
135 | time: 1240983,
136 | value: "ANbf",
137 | },
138 | ];
139 |
140 | expect(getRetrainingIndex(expectedFalse, 1399843)).toBe(-1);
141 | });
142 |
143 | it("returns -1 for test case 3", () => {
144 | const expectedFalse: Player["heroes"][number]["abilityOrder"] = [
145 | {
146 | type: "ability",
147 | time: 1236109,
148 | value: "ANsi",
149 | },
150 | {
151 | type: "ability",
152 | time: 1458928,
153 | value: "ANba",
154 | },
155 | ];
156 | expect(getRetrainingIndex(expectedFalse, 1399843)).toBe(-1);
157 | });
158 |
159 | it("returns -1 for test case 4", () => {
160 | const expectedFalse: Player["heroes"][number]["abilityOrder"] = [
161 | {
162 | type: "ability",
163 | time: 603355,
164 | value: "AHtb",
165 | },
166 | {
167 | type: "ability",
168 | time: 700216,
169 | value: "AHbh",
170 | },
171 | {
172 | type: "ability",
173 | time: 812728,
174 | value: "AHtb",
175 | },
176 | {
177 | type: "ability",
178 | time: 978714,
179 | value: "AHbh",
180 | },
181 | {
182 | type: "ability",
183 | time: 1396510,
184 | value: "AHtc",
185 | },
186 | ];
187 | expect(getRetrainingIndex(expectedFalse, 1399843)).toBe(-1);
188 | });
189 |
190 | it("returns -1 for test case 5", () => {
191 | const expectedFalse: Player["heroes"][number]["abilityOrder"] = [
192 | {
193 | type: "ability",
194 | time: 905886,
195 | value: "AHhb",
196 | },
197 | {
198 | type: "ability",
199 | time: 1028782,
200 | value: "AHds",
201 | },
202 | {
203 | type: "ability",
204 | time: 1404045,
205 | value: "AHhb",
206 | },
207 | {
208 | type: "ability",
209 | time: 1510753,
210 | value: "AHad",
211 | },
212 | ];
213 | expect(getRetrainingIndex(expectedFalse, 1399843)).toBe(-1);
214 | });
215 |
--------------------------------------------------------------------------------
/test/inferHeroAbilityLevelsFromAbilityOrder.test.ts:
--------------------------------------------------------------------------------
1 | import { inferHeroAbilityLevelsFromAbilityOrder } from "../src/inferHeroAbilityLevelsFromAbilityOrder";
2 | import Player, { Ability } from "../src/Player";
3 |
4 | const input: Ability[] = [
5 | {
6 | type: "ability",
7 | time: 126467,
8 | value: "AEfn",
9 | },
10 | {
11 | type: "ability",
12 | time: 178541,
13 | value: "AEer",
14 | },
15 | {
16 | type: "ability",
17 | time: 534905,
18 | value: "AEfn",
19 | },
20 | {
21 | type: "ability",
22 | time: 1016408,
23 | value: "AEer",
24 | },
25 | {
26 | type: "ability",
27 | time: 1907059,
28 | value: "AEer",
29 | },
30 | {
31 | type: "ability",
32 | time: 2091683,
33 | value: "AEtq",
34 | },
35 | {
36 | type: "ability",
37 | time: 2093068,
38 | value: "AEtq",
39 | },
40 | {
41 | type: "ability",
42 | time: 2093226,
43 | value: "AEtq",
44 | },
45 | {
46 | type: "ability",
47 | time: 2093357,
48 | value: "AEtq",
49 | },
50 | {
51 | type: "ability",
52 | time: 2093505,
53 | value: "AEtq",
54 | },
55 | {
56 | type: "ability",
57 | time: 2093617,
58 | value: "AEtq",
59 | },
60 | {
61 | type: "ability",
62 | time: 2093738,
63 | value: "AEtq",
64 | },
65 | {
66 | type: "ability",
67 | time: 2093847,
68 | value: "AEtq",
69 | },
70 | {
71 | type: "ability",
72 | time: 2094002,
73 | value: "AEtq",
74 | },
75 | {
76 | type: "ability",
77 | time: 2094137,
78 | value: "AEtq",
79 | },
80 | {
81 | type: "ability",
82 | time: 2094271,
83 | value: "AEtq",
84 | },
85 | {
86 | type: "ability",
87 | time: 2094393,
88 | value: "AEtq",
89 | },
90 | {
91 | type: "ability",
92 | time: 2094526,
93 | value: "AEtq",
94 | },
95 | {
96 | type: "ability",
97 | time: 2094671,
98 | value: "AEtq",
99 | },
100 | ];
101 |
102 | it("correctly infers that KOTG only has tranquility level 1", () => {
103 | expect(inferHeroAbilityLevelsFromAbilityOrder(input)).toEqual({
104 | finalHeroAbilities: {
105 | AEfn: 2,
106 | AEer: 3,
107 | AEtq: 1,
108 | },
109 | retrainingHistory: [],
110 | });
111 | });
112 |
113 | it("correctly infers the final ability levels and the ability state before tome of retraining was used", () => {
114 | const retrainedAbilityOrder: Player["heroes"][number]["abilityOrder"] = [
115 | {
116 | type: "ability",
117 | time: 125743,
118 | value: "AHwe",
119 | },
120 | {
121 | type: "ability",
122 | time: 167347,
123 | value: "AHab",
124 | },
125 | {
126 | type: "ability",
127 | time: 230430,
128 | value: "AHwe",
129 | },
130 | {
131 | type: "ability",
132 | time: 543939,
133 | value: "AHab",
134 | },
135 | {
136 | type: "ability",
137 | time: 818999,
138 | value: "AHwe",
139 | },
140 | {
141 | type: "ability",
142 | time: 1211048,
143 | value: "AHmt",
144 | },
145 | { type: "retraining", time: 1399843 },
146 | {
147 | type: "ability",
148 | time: 1443410,
149 | value: "AHbz",
150 | },
151 | {
152 | type: "ability",
153 | time: 1443563,
154 | value: "AHbz",
155 | },
156 | {
157 | type: "ability",
158 | time: 1443685,
159 | value: "AHbz",
160 | },
161 | {
162 | type: "ability",
163 | time: 1444048,
164 | value: "AHab",
165 | },
166 | {
167 | type: "ability",
168 | time: 1444231,
169 | value: "AHab",
170 | },
171 | {
172 | type: "ability",
173 | time: 1444384,
174 | value: "AHmt",
175 | },
176 | ];
177 | expect(inferHeroAbilityLevelsFromAbilityOrder(retrainedAbilityOrder)).toEqual(
178 | {
179 | finalHeroAbilities: {
180 | AHbz: 3,
181 | AHab: 2,
182 | AHmt: 1,
183 | },
184 | retrainingHistory: [
185 | {
186 | abilities: {
187 | AHab: 2,
188 | AHmt: 1,
189 | AHwe: 3,
190 | },
191 | time: 1399843,
192 | },
193 | ],
194 | },
195 | );
196 | });
197 |
--------------------------------------------------------------------------------
/test/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: {
3 | ".(ts|tsx)": "ts-jest",
4 | },
5 | rootDir: "..",
6 | testEnvironment: "node",
7 | testRegex: "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
8 | moduleFileExtensions: ["ts", "tsx", "js"],
9 | collectCoverageFrom: ["/src/**/*.ts"],
10 | };
11 |
--------------------------------------------------------------------------------
/test/replays/126/999.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/126/999.w3g
--------------------------------------------------------------------------------
/test/replays/126/replays.test.ts:
--------------------------------------------------------------------------------
1 | import W3GReplay from "../../../src/";
2 | import path from "node:path";
3 | import fs from "node:fs";
4 |
5 | const Parser = new W3GReplay();
6 | it("parses a 2on2standard 1.29 replay properly", async () => {
7 | const test = await Parser.parse(path.resolve(__dirname, "999.w3g"));
8 | expect(test.version).toBe("1.26");
9 | expect(test.players[0].id).toBe(2);
10 | expect(test.players[0].teamid).toBe(0);
11 | expect(test.players[1].id).toBe(4);
12 | expect(test.players[1].teamid).toBe(0);
13 | expect(test.players[2].id).toBe(3);
14 | expect(test.players[2].teamid).toBe(1);
15 | expect(test.players[3].id).toBe(5);
16 | expect(test.players[3].teamid).toBe(1);
17 | expect(test.matchup).toBe("HUvHU");
18 | expect(test.type).toBe("2on2");
19 | expect(test.players.length).toBe(4);
20 | expect(test.map).toEqual({
21 | checksum: "b4230d1e",
22 | checksumSha1: "1f75e2a24fd995a6d7b123bb44d8afae7b5c6222",
23 | file: "w3arena__maelstrom__v2.w3x",
24 | path: "Maps\\w3arena\\w3arena__maelstrom__v2.w3x",
25 | });
26 | fs.writeFileSync(
27 | path.resolve(__dirname + "../../../../examples/output.json"),
28 | JSON.stringify(test, undefined, 2),
29 | );
30 | });
31 |
32 | it("parses a standard 1.26 replay properly", async () => {
33 | const test = await Parser.parse(path.resolve(__dirname, "standard_126.w3g"));
34 | expect(test.version).toBe("1.26");
35 | expect(test.observers.length).toBe(8);
36 | expect(test.players[1].name).toBe("Happy_");
37 | expect(test.players[1].raceDetected).toBe("U");
38 | expect(test.players[1].color).toBe("#0042ff");
39 | expect(test.players[0].name).toBe("u2.sok");
40 | expect(test.players[0].raceDetected).toBe("H");
41 | expect(test.players[0].color).toBe("#ff0303");
42 | expect(test.matchup).toBe("HvU");
43 | expect(test.type).toBe("1on1");
44 | expect(test.players.length).toBe(2);
45 | expect(test.map).toEqual({
46 | checksum: "51a1c63b",
47 | checksumSha1: "0b4f05ca7dcc23b9501422b4fa26a86c7d2a0ee0",
48 | file: "w3arena__amazonia__v3.w3x",
49 | path: "Maps\\w3arena\\w3arena__amazonia__v3.w3x",
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/test/replays/126/standard_126.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/126/standard_126.w3g
--------------------------------------------------------------------------------
/test/replays/129/netease_129_obs.nwg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/129/netease_129_obs.nwg
--------------------------------------------------------------------------------
/test/replays/129/replays.test.ts:
--------------------------------------------------------------------------------
1 | import W3GReplay from "../../../src/";
2 | import path from "node:path";
3 |
4 | const Parser = new W3GReplay();
5 | it("parses a netease 1.29 replay properly", async () => {
6 | const test = await Parser.parse(
7 | path.resolve(__dirname, "netease_129_obs.nwg"),
8 | );
9 | expect(test.version).toBe("1.29");
10 |
11 | expect(test.players[1].name).toBe("rudan");
12 | expect(test.players[1].color).toBe("#282828");
13 | expect(test.observers.length).toBe(1);
14 | expect(test.matchup).toBe("NvN");
15 | expect(test.type).toBe("1on1");
16 | expect(test.players.length).toBe(2);
17 | expect(test.map).toEqual({
18 | checksum: "281f9d6a",
19 | checksumSha1: "c232d68286eb4604cc66db42d45e28017b78e3c4",
20 | file: "(4)TurtleRock.w3x",
21 | path: "Maps/1.29\\(4)TurtleRock.w3x",
22 | });
23 | });
24 |
25 | it("parses a standard 1.29 replay with observers properly", async () => {
26 | const test = await Parser.parse(
27 | path.resolve(__dirname, "standard_129_obs.w3g"),
28 | );
29 |
30 | expect(test.version).toBe("1.29");
31 | expect(test.players[1].name).toBe("S.o.K.o.L");
32 | expect(test.players[1].raceDetected).toBe("O");
33 | expect(test.players[1].id).toBe(4);
34 | expect(test.players[1].teamid).toBe(3);
35 | expect(test.players[1].color).toBe("#00781e");
36 | expect(test.players[1].units.summary).toEqual({
37 | opeo: 10,
38 | ogru: 5,
39 | orai: 6,
40 | ospm: 5,
41 | okod: 2,
42 | });
43 | expect(test.players[1].actions).toEqual({
44 | assigngroup: 38,
45 | rightclick: 1104,
46 | basic: 122,
47 | buildtrain: 111,
48 | ability: 59,
49 | item: 6,
50 | select: 538,
51 | removeunit: 0,
52 | subgroup: 0,
53 | selecthotkey: 751,
54 | esc: 0,
55 | timed: expect.any(Array),
56 | });
57 |
58 | expect(test.players[0].name).toBe("Stormhoof");
59 | expect(test.players[0].raceDetected).toBe("O");
60 | expect(test.players[0].color).toBe("#9b0000");
61 | expect(test.players[0].id).toBe(6);
62 | expect(test.players[0].teamid).toBe(0);
63 | expect(test.players[0].units.summary).toEqual({
64 | opeo: 11,
65 | ogru: 8,
66 | orai: 8,
67 | ospm: 4,
68 | okod: 3,
69 | });
70 | expect(test.players[0].actions).toEqual({
71 | assigngroup: 111,
72 | rightclick: 1595,
73 | basic: 201,
74 | buildtrain: 112,
75 | ability: 57,
76 | item: 5,
77 | select: 653,
78 | removeunit: 0,
79 | subgroup: 0,
80 | selecthotkey: 1865,
81 | esc: 4,
82 | timed: expect.any(Array),
83 | });
84 |
85 | expect(test.observers.length).toBe(4);
86 | expect(test.chat.length).toBeGreaterThan(2);
87 | expect(test.matchup).toBe("OvO");
88 | expect(test.type).toBe("1on1");
89 | expect(test.players.length).toBe(2);
90 | expect(test.parseTime).toBe(Math.round(test.parseTime));
91 | expect(test.map).toEqual({
92 | checksum: "008ab7f1",
93 | checksumSha1: "79ba7579f28e5ccfd741a1ebfbff95a56813086e",
94 | file: "w3arena__twistedmeadows__v3.w3x",
95 | path: "Maps\\w3arena\\w3arena__twistedmeadows__v3.w3x",
96 | });
97 | });
98 |
99 | it("evaluates APM correctly in a team game with an early leaver", async () => {
100 | const test = await Parser.parse(
101 | path.resolve(__dirname, "standard_129_3on3_leaver.w3g"),
102 | );
103 | const firstLeftMinute = Math.ceil(
104 | test.players[0].currentTimePlayed / 1000 / 60,
105 | );
106 | const postLeaveBlocks = test.players[0].actions.timed.slice(firstLeftMinute);
107 | const postLeaveApmSum = postLeaveBlocks.reduce((a, b) => a + b);
108 | expect(test.players[0].name).toBe("abmitdirpic");
109 | expect(postLeaveApmSum).toEqual(0);
110 | expect(test.players[0].apm).toEqual(98);
111 | expect(test.players[0].currentTimePlayed).toEqual(4371069);
112 | expect(Parser.msElapsed).toEqual(6433136);
113 | });
114 |
--------------------------------------------------------------------------------
/test/replays/129/standard_129_3on3_leaver.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/129/standard_129_3on3_leaver.w3g
--------------------------------------------------------------------------------
/test/replays/129/standard_129_obs.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/129/standard_129_obs.w3g
--------------------------------------------------------------------------------
/test/replays/130/__snapshots__/replays.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`parses a standard 1.30.4 2on2 replay properly 1`] = `
4 | {
5 | "actions": {
6 | "ability": 163,
7 | "assigngroup": 60,
8 | "basic": 115,
9 | "buildtrain": 124,
10 | "esc": 9,
11 | "item": 2,
12 | "removeunit": 0,
13 | "rightclick": 1433,
14 | "select": 988,
15 | "selecthotkey": 752,
16 | "subgroup": 0,
17 | "timed": [
18 | 144,
19 | 211,
20 | 282,
21 | 187,
22 | 203,
23 | 181,
24 | 171,
25 | 218,
26 | 208,
27 | 187,
28 | 178,
29 | 176,
30 | 213,
31 | 221,
32 | 232,
33 | 184,
34 | 180,
35 | 193,
36 | 119,
37 | ],
38 | },
39 | "apm": 198,
40 | "buildings": {
41 | "order": [
42 | {
43 | "id": "hbla",
44 | "ms": 33036,
45 | },
46 | {
47 | "id": "halt",
48 | "ms": 36623,
49 | },
50 | {
51 | "id": "hbar",
52 | "ms": 38699,
53 | },
54 | {
55 | "id": "hhou",
56 | "ms": 46647,
57 | },
58 | {
59 | "id": "hhou",
60 | "ms": 62724,
61 | },
62 | {
63 | "id": "hhou",
64 | "ms": 122755,
65 | },
66 | {
67 | "id": "hkee",
68 | "ms": 190494,
69 | },
70 | {
71 | "id": "hhou",
72 | "ms": 210930,
73 | },
74 | {
75 | "id": "hwtw",
76 | "ms": 216711,
77 | },
78 | {
79 | "id": "hatw",
80 | "ms": 248942,
81 | },
82 | {
83 | "id": "hhou",
84 | "ms": 253012,
85 | },
86 | {
87 | "id": "hhou",
88 | "ms": 254028,
89 | },
90 | {
91 | "id": "hvlt",
92 | "ms": 258542,
93 | },
94 | {
95 | "id": "hhou",
96 | "ms": 327627,
97 | },
98 | {
99 | "id": "hhou",
100 | "ms": 485876,
101 | },
102 | {
103 | "id": "htow",
104 | "ms": 504685,
105 | },
106 | {
107 | "id": "hlum",
108 | "ms": 662483,
109 | },
110 | {
111 | "id": "hhou",
112 | "ms": 673657,
113 | },
114 | {
115 | "id": "hatw",
116 | "ms": 933869,
117 | },
118 | {
119 | "id": "htow",
120 | "ms": 1065471,
121 | },
122 | ],
123 | "summary": {
124 | "halt": 1,
125 | "hatw": 2,
126 | "hbar": 1,
127 | "hbla": 1,
128 | "hhou": 9,
129 | "hkee": 1,
130 | "hlum": 1,
131 | "htow": 2,
132 | "hvlt": 1,
133 | "hwtw": 1,
134 | },
135 | },
136 | "color": "#ff0303",
137 | "groupHotkeys": {
138 | "0": {
139 | "assigned": 1,
140 | "used": 128,
141 | },
142 | "1": {
143 | "assigned": 47,
144 | "used": 283,
145 | },
146 | "2": {
147 | "assigned": 7,
148 | "used": 45,
149 | },
150 | "3": {
151 | "assigned": 0,
152 | "used": 0,
153 | },
154 | "4": {
155 | "assigned": 1,
156 | "used": 211,
157 | },
158 | "5": {
159 | "assigned": 0,
160 | "used": 0,
161 | },
162 | "6": {
163 | "assigned": 0,
164 | "used": 0,
165 | },
166 | "7": {
167 | "assigned": 0,
168 | "used": 0,
169 | },
170 | "8": {
171 | "assigned": 2,
172 | "used": 2,
173 | },
174 | "9": {
175 | "assigned": 2,
176 | "used": 83,
177 | },
178 | },
179 | "heroes": [
180 | {
181 | "abilities": {
182 | "AHad": 2,
183 | "AHhb": 2,
184 | },
185 | "abilityOrder": [
186 | {
187 | "time": 166550,
188 | "type": "ability",
189 | "value": "AHhb",
190 | },
191 | {
192 | "time": 226152,
193 | "type": "ability",
194 | "value": "AHad",
195 | },
196 | {
197 | "time": 298939,
198 | "type": "ability",
199 | "value": "AHhb",
200 | },
201 | {
202 | "time": 592029,
203 | "type": "ability",
204 | "value": "AHad",
205 | },
206 | ],
207 | "id": "Hpal",
208 | "level": 4,
209 | "retrainingHistory": [],
210 | },
211 | {
212 | "abilities": {
213 | "AHbn": 1,
214 | "AHdr": 2,
215 | },
216 | "abilityOrder": [
217 | {
218 | "time": 387930,
219 | "type": "ability",
220 | "value": "AHdr",
221 | },
222 | {
223 | "time": 652084,
224 | "type": "ability",
225 | "value": "AHbn",
226 | },
227 | {
228 | "time": 819587,
229 | "type": "ability",
230 | "value": "AHdr",
231 | },
232 | ],
233 | "id": "Hblm",
234 | "level": 3,
235 | "retrainingHistory": [],
236 | },
237 | ],
238 | "id": 5,
239 | "items": {
240 | "order": [
241 | {
242 | "id": "sreg",
243 | "ms": 327162,
244 | },
245 | {
246 | "id": "plcl",
247 | "ms": 341634,
248 | },
249 | {
250 | "id": "tsct",
251 | "ms": 342990,
252 | },
253 | {
254 | "id": "phea",
255 | "ms": 417767,
256 | },
257 | {
258 | "id": "plcl",
259 | "ms": 619906,
260 | },
261 | {
262 | "id": "sreg",
263 | "ms": 690935,
264 | },
265 | {
266 | "id": "plcl",
267 | "ms": 692398,
268 | },
269 | {
270 | "id": "stwp",
271 | "ms": 694301,
272 | },
273 | {
274 | "id": "sreg",
275 | "ms": 829648,
276 | },
277 | {
278 | "id": "phea",
279 | "ms": 833926,
280 | },
281 | {
282 | "id": "sreg",
283 | "ms": 909258,
284 | },
285 | {
286 | "id": "phea",
287 | "ms": 910479,
288 | },
289 | {
290 | "id": "plcl",
291 | "ms": 918180,
292 | },
293 | {
294 | "id": "tsct",
295 | "ms": 926555,
296 | },
297 | {
298 | "id": "sreg",
299 | "ms": 932210,
300 | },
301 | {
302 | "id": "stel",
303 | "ms": 992084,
304 | },
305 | {
306 | "id": "shea",
307 | "ms": 995541,
308 | },
309 | ],
310 | "summary": {
311 | "phea": 3,
312 | "plcl": 4,
313 | "shea": 1,
314 | "sreg": 5,
315 | "stel": 1,
316 | "stwp": 1,
317 | "tsct": 2,
318 | },
319 | },
320 | "name": "Thorzain",
321 | "race": "H",
322 | "raceDetected": "H",
323 | "resourceTransfers": [
324 | {
325 | "gold": 0,
326 | "lumber": 30,
327 | "msElapsed": 82662,
328 | "playerId": 9,
329 | "playerName": "Starshaped",
330 | "slot": 3,
331 | },
332 | {
333 | "gold": 200,
334 | "lumber": 0,
335 | "msElapsed": 736480,
336 | "playerId": 9,
337 | "playerName": "Starshaped",
338 | "slot": 3,
339 | },
340 | ],
341 | "teamid": 3,
342 | "units": {
343 | "order": [
344 | {
345 | "id": "hpea",
346 | "ms": 812,
347 | },
348 | {
349 | "id": "hpea",
350 | "ms": 2885,
351 | },
352 | {
353 | "id": "hpea",
354 | "ms": 8742,
355 | },
356 | {
357 | "id": "hpea",
358 | "ms": 39120,
359 | },
360 | {
361 | "id": "hpea",
362 | "ms": 58282,
363 | },
364 | {
365 | "id": "hpea",
366 | "ms": 68247,
367 | },
368 | {
369 | "id": "hpea",
370 | "ms": 86734,
371 | },
372 | {
373 | "id": "hpea",
374 | "ms": 102922,
375 | },
376 | {
377 | "id": "hrif",
378 | "ms": 112469,
379 | },
380 | {
381 | "id": "hrif",
382 | "ms": 134194,
383 | },
384 | {
385 | "id": "hrif",
386 | "ms": 160527,
387 | },
388 | {
389 | "id": "hrif",
390 | "ms": 202902,
391 | },
392 | {
393 | "id": "hrif",
394 | "ms": 250426,
395 | },
396 | {
397 | "id": "hrif",
398 | "ms": 277323,
399 | },
400 | {
401 | "id": "hrif",
402 | "ms": 307310,
403 | },
404 | {
405 | "id": "hrif",
406 | "ms": 363259,
407 | },
408 | {
409 | "id": "hrif",
410 | "ms": 397380,
411 | },
412 | {
413 | "id": "hrif",
414 | "ms": 410317,
415 | },
416 | {
417 | "id": "hrif",
418 | "ms": 445278,
419 | },
420 | {
421 | "id": "hrif",
422 | "ms": 465282,
423 | },
424 | {
425 | "id": "hrif",
426 | "ms": 525932,
427 | },
428 | {
429 | "id": "hrif",
430 | "ms": 542208,
431 | },
432 | {
433 | "id": "hrif",
434 | "ms": 549121,
435 | },
436 | {
437 | "id": "hpea",
438 | "ms": 550446,
439 | },
440 | {
441 | "id": "hpea",
442 | "ms": 550581,
443 | },
444 | {
445 | "id": "hrif",
446 | "ms": 579797,
447 | },
448 | {
449 | "id": "hrif",
450 | "ms": 593823,
451 | },
452 | {
453 | "id": "hrif",
454 | "ms": 610863,
455 | },
456 | {
457 | "id": "hpea",
458 | "ms": 630067,
459 | },
460 | {
461 | "id": "hpea",
462 | "ms": 630179,
463 | },
464 | {
465 | "id": "hrif",
466 | "ms": 636477,
467 | },
468 | {
469 | "id": "hpea",
470 | "ms": 661473,
471 | },
472 | {
473 | "id": "hrif",
474 | "ms": 680850,
475 | },
476 | {
477 | "id": "hpea",
478 | "ms": 681776,
479 | },
480 | {
481 | "id": "hpea",
482 | "ms": 681972,
483 | },
484 | {
485 | "id": "hpea",
486 | "ms": 712775,
487 | },
488 | {
489 | "id": "hrif",
490 | "ms": 721842,
491 | },
492 | {
493 | "id": "hpea",
494 | "ms": 743320,
495 | },
496 | {
497 | "id": "hrif",
498 | "ms": 757033,
499 | },
500 | {
501 | "id": "hrif",
502 | "ms": 771398,
503 | },
504 | {
505 | "id": "hpea",
506 | "ms": 779182,
507 | },
508 | {
509 | "id": "hrif",
510 | "ms": 802580,
511 | },
512 | {
513 | "id": "hrif",
514 | "ms": 814565,
515 | },
516 | {
517 | "id": "hrif",
518 | "ms": 830732,
519 | },
520 | {
521 | "id": "hrif",
522 | "ms": 852290,
523 | },
524 | {
525 | "id": "hpea",
526 | "ms": 853277,
527 | },
528 | {
529 | "id": "hrif",
530 | "ms": 860086,
531 | },
532 | {
533 | "id": "hrif",
534 | "ms": 878292,
535 | },
536 | {
537 | "id": "hpea",
538 | "ms": 879865,
539 | },
540 | {
541 | "id": "hrif",
542 | "ms": 893893,
543 | },
544 | {
545 | "id": "hrif",
546 | "ms": 916553,
547 | },
548 | {
549 | "id": "hrif",
550 | "ms": 959420,
551 | },
552 | {
553 | "id": "hrif",
554 | "ms": 984615,
555 | },
556 | {
557 | "id": "hrif",
558 | "ms": 1006417,
559 | },
560 | {
561 | "id": "hrif",
562 | "ms": 1035124,
563 | },
564 | {
565 | "id": "hrif",
566 | "ms": 1045427,
567 | },
568 | {
569 | "id": "hrif",
570 | "ms": 1082176,
571 | },
572 | {
573 | "id": "hrif",
574 | "ms": 1112217,
575 | },
576 | ],
577 | "summary": {
578 | "hpea": 20,
579 | "hrif": 38,
580 | },
581 | },
582 | "upgrades": {
583 | "order": [
584 | {
585 | "id": "Rhri",
586 | "ms": 332547,
587 | },
588 | {
589 | "id": "Rhra",
590 | "ms": 623973,
591 | },
592 | ],
593 | "summary": {
594 | "Rhra": 1,
595 | "Rhri": 1,
596 | },
597 | },
598 | }
599 | `;
600 |
--------------------------------------------------------------------------------
/test/replays/130/replays.test.ts:
--------------------------------------------------------------------------------
1 | import W3GReplay from "../../../src/";
2 | import path from "node:path";
3 | import { readFileSync } from "fs";
4 | import { Validator } from "jsonschema";
5 | import schema from "../../schema.json";
6 |
7 | const Parser = new W3GReplay();
8 | it("parses a standard 1.30.2 replay properly", async () => {
9 | const test = await Parser.parse(path.resolve(__dirname, "standard_1302.w3g"));
10 | expect(test.version).toBe("1.30.2+");
11 | expect(test.matchup).toBe("NvU");
12 | expect(test.players.length).toBe(2);
13 | });
14 |
15 | it("parses a standard 1.30.3 replay properly", async () => {
16 | const test = await Parser.parse(path.resolve(__dirname, "standard_1303.w3g"));
17 | expect(test.version).toBe("1.30.2+");
18 | expect(test.players.length).toBe(2);
19 | });
20 |
21 | it("parses a standard 1.30.4 replay properly", async () => {
22 | const test = await Parser.parse(path.resolve(__dirname, "standard_1304.w3g"));
23 | expect(test.version).toBe("1.30.2+");
24 | expect(test.players.length).toBe(2);
25 | });
26 |
27 | it("parses a standard 1.30.4 replay properly as buffer", async () => {
28 | const buffer: Buffer = readFileSync(
29 | path.resolve(__dirname, "standard_1304.w3g"),
30 | );
31 | const test = await Parser.parse(buffer);
32 | expect(test.version).toBe("1.30.2+");
33 | expect(test.players.length).toBe(2);
34 | });
35 |
36 | it("parses a standard 1.30.4 2on2 replay properly", async () => {
37 | const test = await Parser.parse(
38 | path.resolve(__dirname, "standard_1304.2on2.w3g"),
39 | );
40 | expect(test.version).toBe("1.30.2+");
41 | expect(test.buildNumber).toBe(6061);
42 | expect(test.players.length).toBe(4);
43 | expect(test.players[2]).toMatchSnapshot();
44 | });
45 |
46 | it("parses a standard 1.30 replay properly", async () => {
47 | const test = await Parser.parse(path.resolve(__dirname, "standard_130.w3g"));
48 | expect(test.version).toBe("1.30");
49 | expect(test.matchup).toBe("NvU");
50 | expect(test.type).toBe("1on1");
51 | expect(test.players[0].name).toBe("sheik");
52 | expect(test.players[0].race).toBe("U");
53 | expect(test.players[0].raceDetected).toBe("U");
54 | expect(test.players[1].name).toBe("123456789012345");
55 | expect(test.players[1].race).toBe("N");
56 | expect(test.players[1].raceDetected).toBe("N");
57 | expect(test.players.length).toBe(2);
58 | expect(test.players[0].heroes[0]).toEqual(
59 | expect.objectContaining({ id: "Udea", level: 6 }),
60 | );
61 | expect(test.players[0].heroes[1]).toEqual(
62 | expect.objectContaining({ id: "Ulic", level: 6 }),
63 | );
64 | expect(test.players[0].heroes[2]).toEqual(
65 | expect.objectContaining({ id: "Udre", level: 3 }),
66 | );
67 | expect(test.map).toEqual({
68 | file: "(4)TwistedMeadows.w3x",
69 | checksum: "c3cae01d",
70 | checksumSha1: "23dc614cca6fd7ec232fbba4898d318a90b95bc6",
71 | path: "Maps\\FrozenThrone\\(4)TwistedMeadows.w3x",
72 | });
73 | });
74 |
75 | it("parsing result has the correct schema", async () => {
76 | const test = await Parser.parse(path.resolve(__dirname, "standard_130.w3g"));
77 | const validatorInstance = new Validator();
78 | validatorInstance.validate(test, schema, { throwError: true });
79 | });
80 |
81 | it("resets elapsedMS instance property to 0 before parsing another replay", async () => {
82 | await Parser.parse(path.resolve(__dirname, "standard_130.w3g"));
83 | const msElapsed = Parser.msElapsed;
84 | await Parser.parse(path.resolve(__dirname, "standard_130.w3g"));
85 | const msElapsedTwo = Parser.msElapsed;
86 | expect(msElapsed).toEqual(msElapsedTwo);
87 | });
88 |
--------------------------------------------------------------------------------
/test/replays/130/standard_130.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/130/standard_130.w3g
--------------------------------------------------------------------------------
/test/replays/130/standard_1302.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/130/standard_1302.w3g
--------------------------------------------------------------------------------
/test/replays/130/standard_1303.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/130/standard_1303.w3g
--------------------------------------------------------------------------------
/test/replays/130/standard_1304.2on2.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/130/standard_1304.2on2.w3g
--------------------------------------------------------------------------------
/test/replays/130/standard_1304.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/130/standard_1304.w3g
--------------------------------------------------------------------------------
/test/replays/131/action0x7a.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/131/action0x7a.w3g
--------------------------------------------------------------------------------
/test/replays/131/replays.test.ts:
--------------------------------------------------------------------------------
1 | import W3GReplay from "../../../src/";
2 | import path from "node:path";
3 |
4 | const Parser = new W3GReplay();
5 |
6 | it("parses a replay with action 0x7a successfully", async () => {
7 | const test = await Parser.parse(path.resolve(__dirname, "action0x7a.w3g"));
8 | expect(test.version).toBe("1.31");
9 | expect(test.players.length).toBe(1);
10 | });
11 |
12 | it("parses a standard 1.30.4 1on1 tome of retraining", async () => {
13 | const test = await Parser.parse(
14 | path.resolve(__dirname, "standard_tomeofretraining_1.w3g"),
15 | );
16 | expect(test.version).toBe("1.31");
17 | expect(test.buildNumber).toBe(6072);
18 | expect(test.players.length).toBe(2);
19 | expect(test.players[0].heroes[0]).toEqual({
20 | id: "Hamg",
21 | abilities: {
22 | AHab: 2,
23 | AHbz: 2,
24 | },
25 | retrainingHistory: [
26 | {
27 | abilities: {
28 | AHab: 2,
29 | AHwe: 2,
30 | },
31 | time: 1136022,
32 | },
33 | ],
34 | level: 4,
35 | abilityOrder: [
36 | {
37 | time: 124366,
38 | type: "ability",
39 | value: "AHwe",
40 | },
41 | {
42 | time: 234428,
43 | type: "ability",
44 | value: "AHab",
45 | },
46 | {
47 | time: 293007,
48 | type: "ability",
49 | value: "AHwe",
50 | },
51 | {
52 | time: 1060007,
53 | type: "ability",
54 | value: "AHab",
55 | },
56 | {
57 | time: 1136022,
58 | type: "retraining",
59 | },
60 | {
61 | time: 1140944,
62 | type: "ability",
63 | value: "AHbz",
64 | },
65 | {
66 | time: 1141147,
67 | type: "ability",
68 | value: "AHbz",
69 | },
70 | {
71 | time: 1141460,
72 | type: "ability",
73 | value: "AHab",
74 | },
75 | {
76 | time: 1141569,
77 | type: "ability",
78 | value: "AHab",
79 | },
80 | ],
81 | });
82 | });
83 |
84 | test("#86 extracts correct map name", async () => {
85 | const test = await Parser.parse(
86 | path.resolve(__dirname, "roc-losttemple-mapname.w3g"),
87 | );
88 | expect(test.version).toBe("1.31");
89 | expect(test.buildNumber).toBe(6072);
90 | expect(test.map.file).toBe("(4)LostTemple [Unforged 0.5 RoC].w3x");
91 | });
92 |
--------------------------------------------------------------------------------
/test/replays/131/roc-losttemple-mapname.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/131/roc-losttemple-mapname.w3g
--------------------------------------------------------------------------------
/test/replays/131/standard_tomeofretraining_1.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/131/standard_tomeofretraining_1.w3g
--------------------------------------------------------------------------------
/test/replays/132/1448202825.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/1448202825.w3g
--------------------------------------------------------------------------------
/test/replays/132/1582070968.nwg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/1582070968.nwg
--------------------------------------------------------------------------------
/test/replays/132/1582161008.nwg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/1582161008.nwg
--------------------------------------------------------------------------------
/test/replays/132/1640262494.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/1640262494.w3g
--------------------------------------------------------------------------------
/test/replays/132/706266088.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/706266088.w3g
--------------------------------------------------------------------------------
/test/replays/132/benjiii_vs_Scars_Concealed_Hill.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/benjiii_vs_Scars_Concealed_Hill.w3g
--------------------------------------------------------------------------------
/test/replays/132/buildingwin_anxietyperspective.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/buildingwin_anxietyperspective.w3g
--------------------------------------------------------------------------------
/test/replays/132/buildingwin_helpstoneperspective.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/buildingwin_helpstoneperspective.w3g
--------------------------------------------------------------------------------
/test/replays/132/ced_vs_lyn.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/ced_vs_lyn.w3g
--------------------------------------------------------------------------------
/test/replays/132/esl_cup_vs_changer_1.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/esl_cup_vs_changer_1.w3g
--------------------------------------------------------------------------------
/test/replays/132/moju_vs_fly.nwg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/moju_vs_fly.nwg
--------------------------------------------------------------------------------
/test/replays/132/netease_132.nwg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/netease_132.nwg
--------------------------------------------------------------------------------
/test/replays/132/reforged1.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged1.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged2.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged2.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged2010.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged2010.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged_hunter2_privatestring.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged_hunter2_privatestring.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged_metadata_ghostplayer.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged_metadata_ghostplayer.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged_release.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged_release.w3g
--------------------------------------------------------------------------------
/test/replays/132/reforged_truncated_playernames.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/reforged_truncated_playernames.w3g
--------------------------------------------------------------------------------
/test/replays/132/replay_fullobs.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/replay_fullobs.w3g
--------------------------------------------------------------------------------
/test/replays/132/replay_obs_on_defeat.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/replay_obs_on_defeat.w3g
--------------------------------------------------------------------------------
/test/replays/132/replay_randomhero_randomraces.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/replay_randomhero_randomraces.w3g
--------------------------------------------------------------------------------
/test/replays/132/replay_referee.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/replay_referee.w3g
--------------------------------------------------------------------------------
/test/replays/132/replay_teamstogether.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/replay_teamstogether.w3g
--------------------------------------------------------------------------------
/test/replays/132/replays.test.ts:
--------------------------------------------------------------------------------
1 | import { jest, it } from "@jest/globals";
2 | import console from "node:console";
3 | import W3GReplay from "../../../src/";
4 | import path from "node:path";
5 | import {
6 | GameDataBlock,
7 | TimeslotBlock,
8 | } from "../../../src/parsers/GameDataParser";
9 |
10 | const Parser = new W3GReplay();
11 | let spiedConsoleError: jest.Spied | undefined = undefined;
12 | let spiedConsoleInfo: jest.Spied | undefined = undefined;
13 |
14 | afterEach(() => {
15 | spiedConsoleError?.mockReset();
16 | spiedConsoleInfo?.mockReset();
17 | });
18 |
19 | it("parses a reforged replay properly #1", async () => {
20 | const test = await Parser.parse(path.resolve(__dirname, "reforged1.w3g"));
21 | expect(test.version).toBe("1.32");
22 | expect(test.buildNumber).toBe(6091);
23 | expect(test.players.length).toBe(2);
24 | });
25 |
26 | it("parses a reforged replay properly #2", async () => {
27 | const test = await Parser.parse(path.resolve(__dirname, "reforged2.w3g"));
28 | expect(test.version).toBe("1.32");
29 | expect(test.buildNumber).toBe(6091);
30 | expect(test.players.length).toBe(2);
31 | });
32 |
33 | it("parses a replay with new reforged metadata successfully", async () => {
34 | const test = await Parser.parse(path.resolve(__dirname, "reforged2010.w3g"));
35 | expect(test.version).toBe("1.32");
36 | expect(test.buildNumber).toBe(6102);
37 | expect(test.players.length).toBe(6);
38 | expect(test.players[0].name).toBe("BEARAND#1604");
39 | });
40 |
41 | it("parses a reforged replay of version 1.32, build 6105 successfully", async () => {
42 | const test = await Parser.parse(
43 | path.resolve(__dirname, "reforged_release.w3g"),
44 | );
45 | expect(test.version).toBe("1.32");
46 | expect(test.buildNumber).toBe(6105);
47 | expect(test.players.length).toBe(2);
48 | expect(test.players[0].name).toBe("anXieTy#2932");
49 | expect(test.players[1].name).toBe("IroNSoul#22724");
50 | });
51 |
52 | it("parses a replay with hunter2 as privateString between game name and encoded string successfully", async () => {
53 | const test = await Parser.parse(
54 | path.resolve(__dirname, "reforged_hunter2_privatestring.w3g"),
55 | );
56 | expect(test.version).toBe("1.32");
57 | expect(test.buildNumber).toBe(6105);
58 | expect(test.players.length).toBe(2);
59 | expect(test.players[0].name).toBe("pischner#2950");
60 | expect(test.players[1].name).toBe("Wartoni#2638");
61 | });
62 |
63 | it("parses a netease 1.32 replay successfully", async () => {
64 | const test = await Parser.parse(path.resolve(__dirname, "netease_132.nwg"));
65 | expect(test.version).toBe("1.32");
66 | expect(test.buildNumber).toBe(6105);
67 | expect(test.players.length).toBe(2);
68 | expect(test.players[0].name).toBe("HurricaneBo");
69 | expect(test.players[1].name).toBe("SimplyHunteR");
70 | });
71 |
72 | it("parse is a promise that resolves with parser output", async () => {
73 | const Parser = new W3GReplay();
74 | const timeslotBlocks: TimeslotBlock[] = [];
75 | let completedAsyncDummyTask = false;
76 | const metadataCallback = jest.fn();
77 | Parser.on("basic_replay_information", metadataCallback);
78 | Parser.on("gamedatablock", (block: GameDataBlock) => {
79 | if (block.id === 0x1f) {
80 | timeslotBlocks.push(block);
81 | }
82 | });
83 | setTimeout(() => {
84 | completedAsyncDummyTask = true;
85 | }, 0);
86 | const test = await Parser.parse(path.resolve(__dirname, "netease_132.nwg"));
87 | expect(timeslotBlocks.length).toBeGreaterThan(50);
88 | expect(completedAsyncDummyTask).toBe(true);
89 | expect(test.version).toBe("1.32");
90 | expect(test.buildNumber).toBe(6105);
91 | expect(test.players.length).toBe(2);
92 | expect(test.players[0].name).toBe("HurricaneBo");
93 | expect(test.players[1].name).toBe("SimplyHunteR");
94 | expect(metadataCallback).toHaveBeenCalledTimes(1);
95 | });
96 |
97 | it("emits 0x1A player actions", async () => {
98 | const Parser = new W3GReplay();
99 | let amountOf0x1AActions = 0;
100 | Parser.on("gamedatablock", (block: GameDataBlock) => {
101 | if (block.id === 0x1f) {
102 | for (const cmdBlock of block.commandBlocks) {
103 | amountOf0x1AActions += cmdBlock.actions.filter(
104 | (action) => action.id === 0x1a,
105 | ).length;
106 | }
107 | }
108 | });
109 | await Parser.parse(path.resolve(__dirname, "netease_132.nwg"));
110 | expect(amountOf0x1AActions).toBeGreaterThan(0);
111 | });
112 |
113 | it("handles truncated player names in reforged replays", async () => {
114 | const test = await Parser.parse(
115 | path.resolve(__dirname, "reforged_truncated_playernames.w3g"),
116 | );
117 | expect(test.version).toBe("1.32");
118 | expect(test.buildNumber).toBe(6105);
119 | expect(test.players.length).toBe(2);
120 | expect(test.players[0].name).toBe("WaN#1734");
121 | expect(test.players[1].name).toBe("РозовыйПони#228941");
122 | });
123 |
124 | it("ignores a player entry in reforged extraPlayerList that misses in playerList", async () => {
125 | const test = await Parser.parse(
126 | path.resolve(__dirname, "reforged_metadata_ghostplayer.w3g"),
127 | );
128 | expect(test.players).toMatchSnapshot();
129 | });
130 |
131 | it("parses single player replay twistedmeadows.w3g", async () => {
132 | const test = await Parser.parse(
133 | path.resolve(__dirname, "reforged_metadata_ghostplayer.w3g"),
134 | );
135 | expect(test.players).toMatchSnapshot();
136 | });
137 |
138 | it("parses 1.32.8 replay with randomhero and randomraces", async () => {
139 | const test = await Parser.parse(
140 | path.resolve(__dirname, "replay_randomhero_randomraces.w3g"),
141 | );
142 | expect(test.settings.randomHero).toBe(true);
143 | expect(test.settings.randomRaces).toBe(true);
144 | });
145 |
146 | it("parses 1.32.8 replay with fullsharedunitcontrol, teams together and lock teams", async () => {
147 | const test = await Parser.parse(
148 | path.resolve(__dirname, "replay_teamstogether.w3g"),
149 | );
150 | expect(test.settings.fullSharedUnitControl).toBe(true);
151 | expect(test.settings.teamsTogether).toBe(true);
152 | expect(test.settings.fixedTeams).toBe(true);
153 | expect(test.settings.randomHero).toBe(false);
154 | expect(test.settings.randomRaces).toBe(false);
155 | });
156 |
157 | it("parses 1.32.8 replay with full observers", async () => {
158 | const test = await Parser.parse(
159 | path.resolve(__dirname, "replay_fullobs.w3g"),
160 | );
161 | expect(test.settings.observerMode).toBe("FULL");
162 | });
163 |
164 | it("parses 1.32.8 replay with referee setting", async () => {
165 | const test = await Parser.parse(
166 | path.resolve(__dirname, "replay_referee.w3g"),
167 | );
168 | expect(test.settings.observerMode).toBe("REFEREES");
169 | });
170 |
171 | it("parses 1.32.8 replay with observer on defeat setting", async () => {
172 | const test = await Parser.parse(
173 | path.resolve(__dirname, "replay_obs_on_defeat.w3g"),
174 | );
175 | expect(test.settings.observerMode).toBe("ON_DEFEAT");
176 | });
177 |
178 | it("should parse hotkeys correctly", async () => {
179 | const test = await Parser.parse(path.resolve(__dirname, "reforged1.w3g"));
180 | expect(test.players[0].groupHotkeys[1]).toEqual({ assigned: 1, used: 29 });
181 | expect(test.players[0].groupHotkeys[2]).toEqual({ assigned: 1, used: 60 });
182 | expect(test.players[1].groupHotkeys[1]).toEqual({ assigned: 21, used: 106 });
183 | expect(test.players[1].groupHotkeys[2]).toEqual({ assigned: 4, used: 64 });
184 | });
185 |
186 | it("should parse a flo w3c hostbot game correctly", async () => {
187 | const test = await Parser.parse(path.resolve(__dirname, "ced_vs_lyn.w3g"));
188 | expect(test.players).toMatchSnapshot();
189 | });
190 |
191 | it("should return chat mode types correctly", async () => {
192 | const test = await Parser.parse(path.resolve(__dirname, "ced_vs_lyn.w3g"));
193 | expect(test.chat).toMatchSnapshot();
194 | });
195 |
196 | it("should handle a netease replay with rogue playerId 3 CommandDataBlocks correctly", async () => {
197 | spiedConsoleError = jest.spyOn(console, "error").mockReturnValue();
198 | spiedConsoleInfo = jest.spyOn(console, "info").mockReturnValue();
199 |
200 | const test = await Parser.parse(path.resolve(__dirname, "moju_vs_fly.nwg"));
201 |
202 | expect(test.players).toMatchSnapshot();
203 | expect(spiedConsoleInfo).not.toHaveBeenCalled();
204 | expect(spiedConsoleError).toHaveBeenCalledTimes(2);
205 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
206 | 1,
207 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 66",
208 | );
209 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
210 | 2,
211 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 331452",
212 | );
213 | });
214 |
215 | it("should handle a netease replay with rogue playerId 3 CommandDataBlocks correctly #2", async () => {
216 | spiedConsoleError = jest.spyOn(console, "error").mockReturnValue();
217 | spiedConsoleInfo = jest.spyOn(console, "info").mockReturnValue();
218 |
219 | const test = await Parser.parse(path.resolve(__dirname, "1582161008.nwg"));
220 |
221 | expect(test.players).toMatchSnapshot();
222 | expect(spiedConsoleInfo).not.toHaveBeenCalled();
223 | expect(spiedConsoleError).toHaveBeenCalledTimes(2);
224 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
225 | 1,
226 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 66",
227 | );
228 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
229 | 2,
230 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 90750",
231 | );
232 | });
233 |
234 | it("should handle a netease replay with rogue playerId 3 CommandDataBlocks correctly #3", async () => {
235 | spiedConsoleError = jest.spyOn(console, "error").mockReturnValue();
236 | spiedConsoleInfo = jest.spyOn(console, "info").mockReturnValue();
237 |
238 | const test = await Parser.parse(path.resolve(__dirname, "1582070968.nwg"));
239 |
240 | expect(test.players).toMatchSnapshot();
241 | expect(spiedConsoleInfo).not.toHaveBeenCalled();
242 | expect(spiedConsoleError).toHaveBeenCalledTimes(2);
243 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
244 | 1,
245 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 66",
246 | );
247 | expect(spiedConsoleError).toHaveBeenNthCalledWith(
248 | 2,
249 | "detected unknown playerId in CommandBlock: 3 - time elapsed: 294624",
250 | );
251 | });
252 |
253 | it("should parse kotg as level 6", async () => {
254 | const test = await Parser.parse(path.resolve(__dirname, "706266088.w3g"));
255 | expect(test.players[1].heroes[0].level).toBe(6);
256 | });
257 |
258 | describe("winner detection", () => {
259 | it("should set winningTeamId to teamId of winner of game 1640262494.w3g", async () => {
260 | const test = await Parser.parse(path.resolve(__dirname, "1640262494.w3g"));
261 | expect(test.winningTeamId).toBe(0);
262 | expect(
263 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
264 | ).toBe("Happie");
265 | });
266 |
267 | it("should set winningTeamId to teamId of winner of game 1448202825.w3g", async () => {
268 | const test = await Parser.parse(path.resolve(__dirname, "1448202825.w3g"));
269 | expect(test.winningTeamId).toBe(1);
270 | expect(
271 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
272 | ).toBe("ThundeR#31281");
273 | });
274 |
275 | it("should set winningTeamId to teamId of winner of game wan_vs_trunks.w3g", async () => {
276 | const test = await Parser.parse(
277 | path.resolve(__dirname, "wan_vs_trunks.w3g"),
278 | );
279 | expect(test.winningTeamId).toBe(0);
280 | expect(
281 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
282 | ).toBe("WaN#1734");
283 | });
284 | it("should set winningTeamId to teamId of winner of game benjiii_vs_Scars_Concealed_Hill.w3g", async () => {
285 | const test = await Parser.parse(
286 | path.resolve(__dirname, "benjiii_vs_Scars_Concealed_Hill.w3g"),
287 | );
288 | expect(test.winningTeamId).toBe(1);
289 | expect(
290 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
291 | ).toBe("benjiii#1588");
292 | });
293 |
294 | it("should set winningTeamId to teamId of winner of game esl_cup_vs_changer_1.w3g", async () => {
295 | const test = await Parser.parse(
296 | path.resolve(__dirname, "esl_cup_vs_changer_1.w3g"),
297 | );
298 | expect(test.winningTeamId).toBe(0);
299 | expect(
300 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
301 | ).toBe("TapioN#2351");
302 | });
303 |
304 | it("should set winningTeamId to teamId of winner of game buildingwin_anxietyperspective.w3g", async () => {
305 | const test = await Parser.parse(
306 | path.resolve(__dirname, "buildingwin_anxietyperspective.w3g"),
307 | );
308 | expect(test.winningTeamId).toBe(1);
309 | expect(
310 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
311 | ).toBe("anXieTy#2932");
312 | });
313 |
314 | it("should set winningTeamId to teamId of winner of game buildingwin_helpstoneperspective.w3g", async () => {
315 | const test = await Parser.parse(
316 | path.resolve(__dirname, "buildingwin_helpstoneperspective.w3g"),
317 | );
318 | expect(test.winningTeamId).toBe(1);
319 | expect(
320 | test.players.find((player) => player.teamid === test.winningTeamId)!.name,
321 | ).toBe("anXieTy#2932");
322 | });
323 | });
324 |
--------------------------------------------------------------------------------
/test/replays/132/twistedmeadows.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/twistedmeadows.w3g
--------------------------------------------------------------------------------
/test/replays/132/wan_vs_trunks.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/132/wan_vs_trunks.w3g
--------------------------------------------------------------------------------
/test/replays/200/2.0.2-FloTVSavedByWc3.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/2.0.2-FloTVSavedByWc3.w3g
--------------------------------------------------------------------------------
/test/replays/200/2.0.2-LAN-bots.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/2.0.2-LAN-bots.w3g
--------------------------------------------------------------------------------
/test/replays/200/2.0.2-Melee.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/2.0.2-Melee.w3g
--------------------------------------------------------------------------------
/test/replays/200/TempReplay.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/TempReplay.w3g
--------------------------------------------------------------------------------
/test/replays/200/goldmine test.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/goldmine test.w3g
--------------------------------------------------------------------------------
/test/replays/200/replays.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 | import W3GReplay, { MetadataParser, RawParser } from "../../../src";
3 | import path from "node:path";
4 | const Parser = new W3GReplay();
5 |
6 | const consoleLogSpy = jest.spyOn(console, "log");
7 | const consoleErrorSpy = jest.spyOn(console, "error");
8 |
9 | it("recognizes a 'build haunted gold mine' command correctly and adds it to the player's buildings", async () => {
10 | const test = await Parser.parse(path.resolve(__dirname, "goldmine test.w3g"));
11 | expect(test.players[0].buildings.summary).toHaveProperty("ugol", 1);
12 | expect(test.players[0].buildings.order).toEqual([{ id: "ugol", ms: 28435 }]);
13 | });
14 |
15 | it("identifies game version 2 and sets the version number to 2.00", async () => {
16 | const test = await Parser.parse(path.resolve(__dirname, "goldmine test.w3g"));
17 | expect(test.version).toBe("2.00");
18 | });
19 |
20 | it("#191 parses a custom map using UI components encoded in actions successfully and without logging errors", async () => {
21 | const consoleSpy = jest.spyOn(console, "log");
22 | const test = await Parser.parse(path.resolve(__dirname, "TempReplay.w3g"));
23 | expect(test.version).toBe("2.00");
24 | expect(consoleSpy).not.toHaveBeenCalled();
25 | });
26 |
27 | it("detects retraining", async () => {
28 | const test = await Parser.parse(
29 | path.resolve(__dirname, "retrainingissues.w3g"),
30 | );
31 | expect(test.version).toBe("2.00");
32 | expect(test.players[1].heroes[0].abilityOrder).toEqual([
33 | {
34 | type: "ability",
35 | time: 125743,
36 | value: "AHwe",
37 | },
38 | {
39 | type: "ability",
40 | time: 167347,
41 | value: "AHab",
42 | },
43 | {
44 | type: "ability",
45 | time: 230430,
46 | value: "AHwe",
47 | },
48 | {
49 | type: "ability",
50 | time: 543939,
51 | value: "AHab",
52 | },
53 | {
54 | type: "ability",
55 | time: 818999,
56 | value: "AHwe",
57 | },
58 | {
59 | type: "ability",
60 | time: 1211048,
61 | value: "AHmt",
62 | },
63 | { type: "retraining", time: 1399843 },
64 | {
65 | type: "ability",
66 | time: 1443410,
67 | value: "AHbz",
68 | },
69 | {
70 | type: "ability",
71 | time: 1443563,
72 | value: "AHbz",
73 | },
74 | {
75 | type: "ability",
76 | time: 1443685,
77 | value: "AHbz",
78 | },
79 | {
80 | type: "ability",
81 | time: 1444048,
82 | value: "AHab",
83 | },
84 | {
85 | type: "ability",
86 | time: 1444231,
87 | value: "AHab",
88 | },
89 | {
90 | type: "ability",
91 | time: 1444384,
92 | value: "AHmt",
93 | },
94 | ]);
95 | expect(test.players[1].heroes[0].level).toBe(6);
96 | });
97 |
98 | it("parses 2.0.2 replay Reforged data successfully and without logging errors", async () => {
99 | const rawParser = new RawParser();
100 | const file = readFileSync(path.resolve(__dirname, "2.0.2-LAN-bots.w3g"));
101 | const data = await rawParser.parse(file);
102 |
103 | const metadataParser = new MetadataParser();
104 | const metadata = await metadataParser.parse(data.blocks);
105 |
106 | expect(metadata.reforgedPlayerMetadata).toBeDefined();
107 | expect(metadata.reforgedPlayerMetadata.length).toBeGreaterThan(0);
108 |
109 | expect(data.subheader.version).toBe(10100);
110 | expect(consoleLogSpy).not.toHaveBeenCalled();
111 | });
112 |
113 | it("parses 2.0.2 melee replay with chat successfully and without logging errors", async () => {
114 | const parser = new W3GReplay();
115 | await parser.parse(path.resolve(__dirname, "2.0.2-Melee.w3g"));
116 |
117 | expect(parser.chatlog[0].playerId).toBe(1);
118 | expect(parser.chatlog[0].message).toBe("don't hurt me");
119 | expect(parser.chatlog[1].playerId).toBe(2);
120 | expect(parser.chatlog[1].message).toBe("no more");
121 |
122 | expect(consoleLogSpy).not.toHaveBeenCalled();
123 | });
124 |
125 | it("parses 2.0.2 flotv game successfully that was saved with WC3", async () => {
126 | const parser = new W3GReplay();
127 | await parser.parse(path.resolve(__dirname, "2.0.2-FloTVSavedByWc3.w3g"));
128 | expect(consoleLogSpy).not.toHaveBeenCalled();
129 | expect(consoleErrorSpy).not.toHaveBeenCalled();
130 | });
131 |
--------------------------------------------------------------------------------
/test/replays/200/retrainingissues.w3g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PBug90/w3gjs/7e54eb83220c7175c2d699cf5e42c3829450854b/test/replays/200/retrainingissues.w3g
--------------------------------------------------------------------------------
/test/sortfunctions.test.ts:
--------------------------------------------------------------------------------
1 | import { sortPlayers } from "../src/sort";
2 |
3 | describe("sortPlayers", () => {
4 | it("sorts players primarily by teamid ascending and players with same teamid by playerid ascending", () => {
5 | const players = [
6 | {
7 | id: 8,
8 | teamid: 1,
9 | },
10 | {
11 | id: 4,
12 | teamid: 1,
13 | },
14 | {
15 | id: 3,
16 | teamid: 0,
17 | },
18 | {
19 | id: 1,
20 | teamid: 0,
21 | },
22 | ];
23 |
24 | expect(players.sort(sortPlayers)).toEqual([
25 | {
26 | id: 1,
27 | teamid: 0,
28 | },
29 | {
30 | id: 3,
31 | teamid: 0,
32 | },
33 | {
34 | id: 4,
35 | teamid: 1,
36 | },
37 | {
38 | id: 8,
39 | teamid: 1,
40 | },
41 | ]);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "node16",
4 | "lib": ["es2023"],
5 | "module": "node16",
6 | "target": "es2022",
7 | "strict": true,
8 | "sourceMap": true,
9 | "declaration": true,
10 | "allowSyntheticDefaultImports": true,
11 | "experimentalDecorators": true,
12 | "emitDecoratorMetadata": true,
13 | "strictPropertyInitialization": false,
14 | "declarationDir": "dist/types",
15 | "outDir": "dist/lib",
16 | "resolveJsonModule": true,
17 | "esModuleInterop": true,
18 | "typeRoots": ["node_modules/@types"],
19 | "skipLibCheck": true,
20 | "isolatedModules": true
21 | },
22 | "include": ["src"]
23 | }
24 |
--------------------------------------------------------------------------------