Skip to content

Commit

Permalink
feat: new low level EventEmitter API to emit events for replay blocks (
Browse files Browse the repository at this point in the history
…#20)

* refactor: use EventEmitter API to emit events for replay blocks

* improvement: add ReplayParser.ts that handles parsing

* refactor: hook up new dedicated parser class with W3GReplay class

* improvement: reimplemented some typings

* improvement: better typings, fixed chat output

* improvement: introduced more types

* docs: update docs to showcase new low level event emitter api
  • Loading branch information
PBug90 authored Mar 7, 2019
1 parent 3f53ae7 commit b476e5d
Show file tree
Hide file tree
Showing 9 changed files with 436 additions and 415 deletions.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ Uses the excellent https://github.com/keichi/binary-parser to parse the replay f

This parser is aimed to be more modular than other parsers.
You can easily add your custom action parsing logic by overriding the processTimeSlot() function
of a W3GReplay instance.
of a W3GReplay instance or by listening for one of the five main events:



**It does not fully support replays of game version <= 1.14.**

Expand All @@ -18,10 +20,31 @@ of a W3GReplay instance.
```

## Usage

### High Level API

```javascript
const W3GReplay = require('w3gjs')
const Parser = new W3GReplay()
const replay = Parser.parse('./replays/sample1.w3g')
console.log(replay)
```

### Low Level API
Low level API allows you to either implement your own logic on top of the ReplayParser class by extending it or
to listen for its callbacks for specific events.

```javascript
const W3GReplay = require('w3gjs')
const Parser = new W3GReplay()

Parser.on('gamemetadata', (metadata) => console.log(metadata))
Parser.on('gamedatablock', (block) => console.log('game data block.'))
Parser.on('timeslotblock', (block) => console.log('time slot block.', Parser.msElapsed))
Parser.on('commandblock', (block) => console.log('command block.'))
Parser.on('actionblock', (block) => console.log('action block.'))

Parser.parse('./replays/999.w3g')
```

### Example output of the observers.w3g example replay
Expand Down
21 changes: 20 additions & 1 deletion example.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,28 @@ function formatBytes (bytes, decimals) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

/*
Chronological order of Blocks when calling EventEmitters callbacks is guaranteed.
You can decide how to proceed and what kind of blocks you want to know about.
Check the W3GReplay.ts file for a reference on how to listen to the callbacks.
If you need to track the current time, access W3GParser.msElapsed property which keeps track of
the time elapsed while parsing the replay file.
W3GParser.on('gamedatablock', (block) => console.log('game data block.'))
W3GParser.on('timeslotblock', (block) => console.log('time slot block.', W3GParser.msElapsed))
W3GParser.on('commandblock', (block) => console.log('command block.'))
W3GParser.on('actionblock', (block) => console.log('action block.'))
*/

replays.forEach((replayFile) => {
const parseResult = W3GParser.parse(`./replays/${replayFile}`)
console.log(parseResult.version)
// console.log(parseResult)
console.log(replayFile)
console.log(replayFile, formatBytes(Buffer.byteLength(JSON.stringify(parseResult), 'utf8')))
console.log('=====')
// process.exit()
})
40 changes: 21 additions & 19 deletions src/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const isObjectId = (input: string) => ['u', 'e', 'h', 'o'].includes(input[0])
const isRightclickAction = (input: number[]) => input[0] === 0x03 && input[1] === 0
const isBasicAction = (input: number[]) => input[0] <= 0x19 && input[1] === 0

interface heroInfo {
level: number,
abilities: { [key: string]: number },
order: number,
id: string
}

class Player {
id: number
name: string
Expand All @@ -33,14 +40,8 @@ class Player {
summary: { [key: string]: number },
order: { id: string, ms: number }[]
}
heroes: {
[key: string]: {
level: number,
abilities: { [key: string]: number },
order: number,
id: string
}
}
heroes:heroInfo[]
heroCollector: {[key: string]: heroInfo}
heroSkills: { [key: string]: number }
heroCount: number
actions: {
Expand All @@ -58,6 +59,7 @@ class Player {
esc: number
}
_currentlyTrackedAPM: number
_lastActionWasDeselect: boolean
currentTimePlayed: number
apm: number

Expand All @@ -72,7 +74,8 @@ class Player {
this.upgrades = { summary: {}, order: [] }
this.items = { summary: {}, order: [] }
this.buildings = { summary: {}, order: [] }
this.heroes = {}
this.heroes = []
this.heroCollector = {}
this.heroSkills = {}
this.heroCount = 0
this.actions = {
Expand All @@ -90,6 +93,7 @@ class Player {
esc: 0
}
this._currentlyTrackedAPM = 0
this._lastActionWasDeselect = false
this.currentTimePlayed = 0
this.apm = 0
return this
Expand Down Expand Up @@ -134,13 +138,13 @@ class Player {
}

handleHeroSkill(actionId: string): void {
if (this.heroes[abilityToHero[actionId]] === undefined) {
if (this.heroCollector[abilityToHero[actionId]] === undefined) {
this.heroCount += 1
this.heroes[abilityToHero[actionId]] = { level: 0, abilities: {}, order: this.heroCount, id: abilityToHero[actionId] }
this.heroCollector[abilityToHero[actionId]] = { level: 0, abilities: {}, order: this.heroCount, id: abilityToHero[actionId] }
}
this.heroes[abilityToHero[actionId]].level += 1
this.heroes[abilityToHero[actionId]].abilities[actionId] = this.heroes[abilityToHero[actionId]].abilities[actionId] || 0
this.heroes[abilityToHero[actionId]].abilities[actionId] += 1
this.heroCollector[abilityToHero[actionId]].level += 1
this.heroCollector[abilityToHero[actionId]].abilities[actionId] = this.heroCollector[abilityToHero[actionId]].abilities[actionId] || 0
this.heroCollector[abilityToHero[actionId]].abilities[actionId] += 1
}

handle0x10(actionId: string, gametime: number): void {
Expand Down Expand Up @@ -222,7 +226,7 @@ class Player {
this._currentlyTrackedAPM ++
}

handle0x16(selectMode: any, isAPM: any) {
handle0x16(selectMode: number, isAPM: boolean) {
if (isAPM) {
this.actions['select'] = this.actions['select'] + 1 || 1
this._currentlyTrackedAPM ++
Expand Down Expand Up @@ -259,13 +263,11 @@ class Player {
cleanup(): void {
const apmSum = this.actions.timed.reduce((a: number, b: number): number => a + b)
this.apm = Math.round(apmSum / this.actions.timed.length)
// @ts-ignore
this.heroes = Object.values(this.heroes).sort((h1, h2) => h1.order - h2.order).reduce((aggregator, hero) => {
this.heroes = Object.values(this.heroCollector).sort((h1, h2) => h1.order - h2.order).reduce((aggregator, hero) => {
delete hero['order']
// @ts-ignore
aggregator.push(hero)
return aggregator
}, [])
}, <heroInfo[]>[])

delete this._currentlyTrackedAPM
}
Expand Down
127 changes: 127 additions & 0 deletions src/ReplayParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Parser } from 'binary-parser'
import { ActionBlockList } from './parsers/actions'
import { ReplayHeader, EncodedMapMetaString, GameMetaData } from './parsers/header'
import { GameDataParser } from './parsers/gamedata'

import {
TimeSlotBlock,
CommandDataBlock,
GameDataBlock,
ActionBlock,
CompressedDataBlock
} from './types'

// Cannot import node modules directly because error with rollup
// https://rollupjs.org/guide/en#error-name-is-not-exported-by-module-
const { readFileSync } = require('fs')
const { inflateSync, constants } = require('zlib')
const GameDataParserComposed = new Parser()
.nest('meta', { type: GameMetaData })
.nest('blocks', { type: GameDataParser })
const EventEmitter = require('events')

class ReplayParser extends EventEmitter{
filename: string
buffer: Buffer
msElapsed: number = 0

constructor() {
super()
this.buffer = Buffer.from('')
this.filename= ''
}

parse($buffer: string) {
console.time('parse')
this.buffer = readFileSync($buffer)
this.buffer = this.buffer.slice(this.buffer.indexOf('Warcraft III recorded game'))
this.filename = $buffer
const decompressed: Buffer[] = []

this._parseHeader()

this.header.blocks.forEach((block: CompressedDataBlock ) => {
if (block.blockSize > 0 && block.blockDecompressedSize === 8192) {
try {
const r = inflateSync(block.compressed, { finishFlush: constants.Z_SYNC_FLUSH })
if (r.byteLength > 0 && block.compressed.byteLength > 0) {
decompressed.push(r)
}
} catch (ex) {
console.log(ex)
}
}
})
this.decompressed = Buffer.concat(decompressed)

this.gameMetaDataDecoded = GameDataParserComposed.parse(this.decompressed)
const decodedMetaStringBuffer = this.decodeGameMetaString(this.gameMetaDataDecoded.meta.encodedString)
const meta = { ...this.gameMetaDataDecoded, ...this.gameMetaDataDecoded.meta, ...EncodedMapMetaString.parse(decodedMetaStringBuffer) }
let newMeta = meta
delete newMeta.meta
this.emit('gamemetadata', newMeta)
this._parseGameDataBlocks()
console.timeEnd('parse')
}

_parseHeader(){
this.header = ReplayHeader.parse(this.buffer)
}

_parseGameDataBlocks(){
this.gameMetaDataDecoded.blocks.forEach((block: GameDataBlock) => {
this.emit('gamedatablock', block)
this._processGameDataBlock(block)
})
}

_processGameDataBlock(block: GameDataBlock) {
switch (block.type) {
case 31:
case 30:
this.msElapsed += block.timeIncrement
this.emit('timeslotblock', <TimeSlotBlock> <unknown> block)
this._processTimeSlot( <TimeSlotBlock> <unknown> block)
break
}
}

_processTimeSlot(timeSlotBlock: TimeSlotBlock): void {
timeSlotBlock.actions.forEach((block: CommandDataBlock): void => {
this._processCommandDataBlock(block)
this.emit('commandblock', block)
})
}

_processCommandDataBlock(actionBlock: CommandDataBlock) {
try {
ActionBlockList.parse(actionBlock.actions).forEach((action: ActionBlock): void => {
this.emit('actionblock', action, actionBlock.playerId)
})
} catch (ex) {
console.error(ex)
}
}

decodeGameMetaString(str: string): Buffer {
let test = Buffer.from(str, 'hex')
let decoded = Buffer.alloc(test.length)
let mask = 0
let dpos = 0

for (let i = 0; i < test.length; i++) {
if (i % 8 === 0) {
mask = test[i]
} else {
if ((mask & (0x1 << (i % 8))) === 0) {
decoded.writeUInt8(test[i] - 1, dpos++)
} else {
decoded.writeUInt8(test[i], dpos++)
}
}
}
return decoded
}
}

export default ReplayParser
Loading

0 comments on commit b476e5d

Please sign in to comment.