diff --git a/.eslintrc.js b/.eslintrc.js index 27bae1dd..d808cce9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,7 +24,6 @@ module.exports = { varsIgnorePattern: "^_", }, ], - "object-shorthand": ["error", "never"], "strict-booleans/no-nullable-numbers": "error", }, }; diff --git a/src/SlippiGame.ts b/src/SlippiGame.ts index b52a1560..ac69ba24 100644 --- a/src/SlippiGame.ts +++ b/src/SlippiGame.ts @@ -12,7 +12,6 @@ import { InputComputer, Stats, StatsType, - getSinglesPlayerPermutationsFromSettings, generateOverallStats, StatOptions, } from "./stats"; @@ -59,8 +58,7 @@ export class SlippiGame { ); this.parser = new SlpParser(); this.parser.on(SlpParserEvent.SETTINGS, (settings) => { - const playerPermutations = getSinglesPlayerPermutationsFromSettings(settings); - this.statsComputer.setPlayerPermutations(playerPermutations); + this.statsComputer.setup(settings); }); // Use finalized frames for stats computation this.parser.on(SlpParserEvent.FINALIZED_FRAME, (frame: FrameEntryType) => { @@ -115,21 +113,25 @@ export class SlippiGame { return this.parser.getFrames(); } - public getStats(): StatsType { + public getStats(): StatsType | null { if (this.finalStats) { return this.finalStats; } this._process(); + const settings = this.parser.getSettings(); + if (settings === null) { + return null; + } + // Finish processing if we're not up to date this.statsComputer.process(); const inputs = this.inputComputer.fetch(); const stocks = this.stockComputer.fetch(); const conversions = this.conversionComputer.fetch(); - const indices = getSinglesPlayerPermutationsFromSettings(this.parser.getSettings()!); const playableFrames = this.parser.getPlayableFrameCount(); - const overall = generateOverallStats(indices, inputs, stocks, conversions, playableFrames); + const overall = generateOverallStats(settings, inputs, stocks, conversions, playableFrames); const stats = { lastFrame: this.parser.getLatestFrameNumber(), diff --git a/src/stats/actions.ts b/src/stats/actions.ts index a15fa5fe..be27ff6b 100644 --- a/src/stats/actions.ts +++ b/src/stats/actions.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import { State, PlayerIndexedType, ActionCountsType } from "./common"; -import { FrameEntryType } from "../types"; +import { State, ActionCountsType } from "./common"; +import { FrameEntryType, GameStartType } from "../types"; import { StatComputer } from "./stats"; // Frame pattern that indicates a dash dance turn was executed @@ -12,15 +12,17 @@ interface PlayerActionState { } export class ActionsComputer implements StatComputer { - private playerPermutations = new Array(); - private state = new Map(); + private playerIndices: number[] = []; + private state = new Map(); - public setPlayerPermutations(playerPermutations: PlayerIndexedType[]): void { - this.playerPermutations = playerPermutations; - this.playerPermutations.forEach((indices) => { + public setup(settings: GameStartType): void { + // Reset the state + this.state = new Map(); + + this.playerIndices = settings.players.map((p) => p.playerIndex); + this.playerIndices.forEach((playerIndex) => { const playerCounts: ActionCountsType = { - playerIndex: indices.playerIndex, - opponentIndex: indices.opponentIndex, + playerIndex, wavedashCount: 0, wavelandCount: 0, airDodgeCount: 0, @@ -35,15 +37,15 @@ export class ActionsComputer implements StatComputer { playerCounts: playerCounts, animations: [], }; - this.state.set(indices, playerState); + this.state.set(playerIndex, playerState); }); } public processFrame(frame: FrameEntryType): void { - this.playerPermutations.forEach((indices) => { - const state = this.state.get(indices); + this.playerIndices.forEach((index) => { + const state = this.state.get(index); if (state) { - handleActionCompute(state, indices, frame); + handleActionCompute(state, index, frame); } }); } @@ -101,8 +103,8 @@ function didStartLedgegrab(currentAnimation: State, previousAnimation: State): b return isCurrentlyGrabbingLedge && !wasPreviouslyGrabbingLedge; } -function handleActionCompute(state: PlayerActionState, indices: PlayerIndexedType, frame: FrameEntryType): void { - const playerFrame = frame.players[indices.playerIndex]!.post; +function handleActionCompute(state: PlayerActionState, playerIndex: number, frame: FrameEntryType): void { + const playerFrame = frame.players[playerIndex]!.post; const incrementCount = (field: string, condition: boolean): void => { if (!condition) { return; diff --git a/src/stats/combos.ts b/src/stats/combos.ts index 0152ac23..d8581ab6 100644 --- a/src/stats/combos.ts +++ b/src/stats/combos.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import { FrameEntryType, FramesType, PostFrameUpdateType } from "../types"; -import { MoveLandedType, ComboType, PlayerIndexedType } from "./common"; +import { FrameEntryType, FramesType, PostFrameUpdateType, GameStartType } from "../types"; +import { MoveLandedType, ComboType } from "./common"; import { isDamaged, isGrabbed, @@ -22,13 +22,17 @@ interface ComboState { } export class ComboComputer implements StatComputer { - private playerPermutations = new Array(); - private state = new Map(); - private combos = new Array(); + private playerIndices: number[] = []; + private combos: ComboType[] = []; + private state = new Map(); - public setPlayerPermutations(playerPermutations: PlayerIndexedType[]): void { - this.playerPermutations = playerPermutations; - this.playerPermutations.forEach((indices) => { + public setup(settings: GameStartType): void { + // Reset the state + this.state = new Map(); + this.combos = []; + + this.playerIndices = settings.players.map((p) => p.playerIndex); + this.playerIndices.forEach((indices) => { const playerState: ComboState = { combo: null, move: null, @@ -40,10 +44,10 @@ export class ComboComputer implements StatComputer { } public processFrame(frame: FrameEntryType, allFrames: FramesType): void { - this.playerPermutations.forEach((indices) => { - const state = this.state.get(indices); + this.playerIndices.forEach((index) => { + const state = this.state.get(index); if (state) { - handleComboCompute(allFrames, state, indices, frame, this.combos); + handleComboCompute(allFrames, state, index, frame, this.combos); } }); } @@ -56,29 +60,20 @@ export class ComboComputer implements StatComputer { function handleComboCompute( frames: FramesType, state: ComboState, - indices: PlayerIndexedType, + playerIndex: number, frame: FrameEntryType, combos: ComboType[], ): void { const currentFrameNumber = frame.frame; - const playerFrame = frame.players[indices.playerIndex]!.post; - const opponentFrame = frame.players[indices.opponentIndex]!.post; + const playerFrame = frame.players[playerIndex]!.post; const prevFrameNumber = currentFrameNumber - 1; let prevPlayerFrame: PostFrameUpdateType | null = null; - let prevOpponentFrame: PostFrameUpdateType | null = null; if (frames[prevFrameNumber]) { - prevPlayerFrame = frames[prevFrameNumber].players[indices.playerIndex]!.post; - prevOpponentFrame = frames[prevFrameNumber].players[indices.opponentIndex]!.post; + prevPlayerFrame = frames[prevFrameNumber].players[playerIndex]!.post; } - const oppActionStateId = opponentFrame.actionStateId!; - const opntIsDamaged = isDamaged(oppActionStateId); - const opntIsGrabbed = isGrabbed(oppActionStateId); - const opntIsCommandGrabbed = isCommandGrabbed(oppActionStateId); - const opntDamageTaken = prevOpponentFrame ? calcDamageTaken(opponentFrame, prevOpponentFrame) : 0; - // Keep track of whether actionState changes after a hit. Used to compute move count // When purely using action state there was a bug where if you did two of the same // move really fast (such as ganon's jab), it would count as one move. Added @@ -93,34 +88,49 @@ function handleComboCompute( state.lastHitAnimation = null; } - // If opponent took damage and was put in some kind of stun this frame, either + const playerActionStateId = playerFrame.actionStateId!; + const playerIsDamaged = isDamaged(playerActionStateId); + const playerIsGrabbed = isGrabbed(playerActionStateId); + const playerIsCommandGrabbed = isCommandGrabbed(playerActionStateId); + + // If the player took damage and was put in some kind of stun this frame, either // start a combo or count the moves for the existing combo - if (opntIsDamaged || opntIsGrabbed || opntIsCommandGrabbed) { + if (playerIsDamaged || playerIsGrabbed || playerIsCommandGrabbed) { if (!state.combo) { state.combo = { - playerIndex: indices.playerIndex, - opponentIndex: indices.opponentIndex, + playerIndex, startFrame: currentFrameNumber, endFrame: null, - startPercent: prevOpponentFrame ? prevOpponentFrame.percent ?? 0 : 0, - currentPercent: opponentFrame.percent ?? 0, + startPercent: prevPlayerFrame ? prevPlayerFrame.percent ?? 0 : 0, + currentPercent: playerFrame.percent ?? 0, endPercent: null, moves: [], didKill: false, + lastHitBy: null, }; combos.push(state.combo); } - if (opntDamageTaken) { + const playerDamageTaken = prevPlayerFrame ? calcDamageTaken(playerFrame, prevPlayerFrame) : 0; + if (playerDamageTaken) { // If animation of last hit has been cleared that means this is a new move. This // prevents counting multiple hits from the same move such as fox's drill + let lastHitBy = playerFrame.lastHitBy ?? playerIndex; + if (playerFrame.lastHitBy === null || playerFrame.lastHitBy > 4) { + lastHitBy = playerIndex; + } + + // Update who hit us last + state.combo.lastHitBy = lastHitBy; + if (state.lastHitAnimation === null) { state.move = { frame: currentFrameNumber, - moveId: playerFrame.lastAttackLanded!, + moveId: frame.players[lastHitBy]!.post!.lastAttackLanded!, hitCount: 0, damage: 0, + playerIndex: lastHitBy, }; state.combo.moves.push(state.move); @@ -128,7 +138,7 @@ function handleComboCompute( if (state.move) { state.move.hitCount += 1; - state.move.damage += opntDamageTaken; + state.move.damage += playerDamageTaken; } // Store previous frame animation to consider the case of a trade, the previous @@ -143,18 +153,25 @@ function handleComboCompute( return; } - const opntIsTeching = isTeching(oppActionStateId); - const opntIsDowned = isDown(oppActionStateId); - const opntDidLoseStock = prevOpponentFrame && didLoseStock(opponentFrame, prevOpponentFrame); - const opntIsDying = isDead(oppActionStateId); + const playerIsTeching = isTeching(playerActionStateId); + const playerIsDowned = isDown(playerActionStateId); + const playerDidLoseStock = prevPlayerFrame && didLoseStock(playerFrame, prevPlayerFrame); + const playerIsDying = isDead(playerActionStateId); - // Update percent if opponent didn't lose stock - if (!opntDidLoseStock) { - state.combo.currentPercent = opponentFrame.percent ?? 0; + // Update percent if the player didn't lose stock + if (!playerDidLoseStock) { + state.combo.currentPercent = playerFrame.percent ?? 0; } - if (opntIsDamaged || opntIsGrabbed || opntIsCommandGrabbed || opntIsTeching || opntIsDowned || opntIsDying) { - // If opponent got grabbed or damaged, reset the reset counter + if ( + playerIsDamaged || + playerIsGrabbed || + playerIsCommandGrabbed || + playerIsTeching || + playerIsDowned || + playerIsDying + ) { + // If the player got grabbed or damaged, reset the reset counter state.resetCounter = 0; } else { state.resetCounter += 1; @@ -162,8 +179,8 @@ function handleComboCompute( let shouldTerminate = false; - // Termination condition 1 - player kills opponent - if (opntDidLoseStock) { + // Termination condition 1 - player was killed + if (playerDidLoseStock) { state.combo.didKill = true; shouldTerminate = true; } @@ -176,7 +193,7 @@ function handleComboCompute( // If combo should terminate, mark the end states and add it to list if (shouldTerminate) { state.combo.endFrame = playerFrame.frame; - state.combo.endPercent = prevOpponentFrame ? prevOpponentFrame.percent ?? 0 : 0; + state.combo.endPercent = prevPlayerFrame ? prevPlayerFrame.percent ?? 0 : 0; state.combo = null; state.move = null; diff --git a/src/stats/common.ts b/src/stats/common.ts index 8b7a3cda..1fadeeb1 100644 --- a/src/stats/common.ts +++ b/src/stats/common.ts @@ -1,5 +1,5 @@ import _ from "lodash"; -import { PostFrameUpdateType, GameStartType } from "../types"; +import { PostFrameUpdateType } from "../types"; export interface StatsType { gameComplete: boolean; @@ -18,11 +18,6 @@ export interface RatioType { ratio: number | null; } -export interface PlayerIndexedType { - playerIndex: number; - opponentIndex: number; -} - export interface DurationType { startFrame: number; endFrame?: number | null; @@ -34,7 +29,8 @@ export interface DamageType { endPercent?: number | null; } -export interface StockType extends PlayerIndexedType, DurationType, DamageType { +export interface StockType extends DurationType, DamageType { + playerIndex: number; count: number; deathAnimation?: number | null; } @@ -44,20 +40,22 @@ export interface MoveLandedType { moveId: number; hitCount: number; damage: number; + playerIndex: number; } -export interface ConversionType extends PlayerIndexedType, DurationType, DamageType { +export interface ComboType extends DurationType, DamageType { + playerIndex: number; moves: MoveLandedType[]; - openingType: string; didKill: boolean; + lastHitBy: number | null; } -export interface ComboType extends PlayerIndexedType, DurationType, DamageType { - moves: MoveLandedType[]; - didKill: boolean; +export interface ConversionType extends ComboType { + openingType: string; } -export interface ActionCountsType extends PlayerIndexedType { +export interface ActionCountsType { + playerIndex: number; wavedashCount: number; wavelandCount: number; airDodgeCount: number; @@ -77,7 +75,8 @@ export interface InputCountsType { total: number; } -export interface OverallType extends PlayerIndexedType { +export interface OverallType { + playerIndex: number; inputCounts: InputCountsType; conversionCount: number; totalDamage: number; @@ -157,30 +156,19 @@ export const Timers = { COMBO_STRING_RESET_FRAMES: 45, }; -export function getSinglesPlayerPermutationsFromSettings(settings: GameStartType): PlayerIndexedType[] { - if (!settings || settings.players.length !== 2) { - // Only return opponent indices for singles - return []; +export function didLoseStock( + frame: PostFrameUpdateType | undefined, + prevFrame: PostFrameUpdateType | undefined, +): boolean { + if (!frame || !prevFrame) { + return false; } - return [ - { - playerIndex: settings.players[0].playerIndex, - opponentIndex: settings.players[1].playerIndex, - }, - { - playerIndex: settings.players[1].playerIndex, - opponentIndex: settings.players[0].playerIndex, - }, - ]; -} - -export function didLoseStock(frame: PostFrameUpdateType, prevFrame: PostFrameUpdateType): boolean { - if (!frame || !prevFrame) { + if (prevFrame.stocksRemaining === null || frame.stocksRemaining === null) { return false; } - return prevFrame.stocksRemaining! - frame.stocksRemaining! > 0; + return prevFrame.stocksRemaining - frame.stocksRemaining > 0; } export function isInControl(state: number): boolean { diff --git a/src/stats/conversions.ts b/src/stats/conversions.ts index 6aecab91..46cceb67 100644 --- a/src/stats/conversions.ts +++ b/src/stats/conversions.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import { FrameEntryType, FramesType, PostFrameUpdateType } from "../types"; -import { MoveLandedType, ConversionType, PlayerIndexedType } from "./common"; +import { FrameEntryType, FramesType, GameStartType, PostFrameUpdateType } from "../types"; +import { MoveLandedType, ConversionType } from "./common"; import { isDamaged, isGrabbed, isCommandGrabbed, calcDamageTaken, isInControl, didLoseStock, Timers } from "./common"; import { StatComputer } from "./stats"; @@ -18,9 +18,9 @@ interface MetadataType { } export class ConversionComputer implements StatComputer { - private playerPermutations = new Array(); - private conversions = new Array(); - private state = new Map(); + private playerIndices: number[] = []; + private conversions: ConversionType[] = []; + private state = new Map(); private metadata: MetadataType; public constructor() { @@ -29,24 +29,31 @@ export class ConversionComputer implements StatComputer { }; } - public setPlayerPermutations(playerPermutations: PlayerIndexedType[]): void { - this.playerPermutations = playerPermutations; - this.playerPermutations.forEach((indices) => { + public setup(settings: GameStartType): void { + // Reset the state since it's a new game + this.playerIndices = settings.players.map((p) => p.playerIndex); + this.conversions = []; + this.state = new Map(); + this.metadata = { + lastEndFrameByOppIdx: {}, + }; + + this.playerIndices.forEach((index) => { const playerState: PlayerConversionState = { conversion: null, move: null, resetCounter: 0, lastHitAnimation: null, }; - this.state.set(indices, playerState); + this.state.set(index, playerState); }); } public processFrame(frame: FrameEntryType, allFrames: FramesType): void { - this.playerPermutations.forEach((indices) => { - const state = this.state.get(indices); + this.playerIndices.forEach((index) => { + const state = this.state.get(index); if (state) { - handleConversionCompute(allFrames, state, indices, frame, this.conversions); + handleConversionCompute(allFrames, state, index, frame, this.conversions); } }); } @@ -80,10 +87,12 @@ export class ConversionComputer implements StatComputer { conversion.openingType = "trade"; return; } - - // If not trade, check the opponent endFrame - const oppEndFrame = this.metadata.lastEndFrameByOppIdx[conversion.opponentIndex]; - const isCounterAttack = oppEndFrame && oppEndFrame > conversion.startFrame; + const lastMove = _.last(conversion.moves); + // If not trade, check the player endFrame + const playerEndFrame = this.metadata.lastEndFrameByOppIdx[ + lastMove ? lastMove.playerIndex : conversion.playerIndex + ]; + const isCounterAttack = playerEndFrame && playerEndFrame > conversion.startFrame; conversion.openingType = isCounterAttack ? "counter-attack" : "neutral-win"; }); }); @@ -93,29 +102,20 @@ export class ConversionComputer implements StatComputer { function handleConversionCompute( frames: FramesType, state: PlayerConversionState, - indices: PlayerIndexedType, + playerIndex: number, frame: FrameEntryType, conversions: ConversionType[], ): void { const currentFrameNumber = frame.frame; - const playerFrame: PostFrameUpdateType = frame.players[indices.playerIndex]!.post; - const opponentFrame = frame.players[indices.opponentIndex]!.post; + const playerFrame: PostFrameUpdateType = frame.players[playerIndex]!.post; const prevFrameNumber = currentFrameNumber - 1; let prevPlayerFrame: PostFrameUpdateType | null = null; - let prevOpponentFrame: PostFrameUpdateType | null = null; if (frames[prevFrameNumber]) { - prevPlayerFrame = frames[prevFrameNumber].players[indices.playerIndex]!.post; - prevOpponentFrame = frames[prevFrameNumber].players[indices.opponentIndex]!.post; + prevPlayerFrame = frames[prevFrameNumber].players[playerIndex]!.post; } - const oppActionStateId = opponentFrame.actionStateId!; - const opntIsDamaged = isDamaged(oppActionStateId); - const opntIsGrabbed = isGrabbed(oppActionStateId); - const opntIsCommandGrabbed = isCommandGrabbed(oppActionStateId); - const opntDamageTaken = prevOpponentFrame ? calcDamageTaken(opponentFrame, prevOpponentFrame) : 0; - // Keep track of whether actionState changes after a hit. Used to compute move count // When purely using action state there was a bug where if you did two of the same // move really fast (such as ganon's jab), it would count as one move. Added @@ -130,35 +130,50 @@ function handleConversionCompute( state.lastHitAnimation = null; } - // If opponent took damage and was put in some kind of stun this frame, either + const playerActionStateId = playerFrame.actionStateId!; + const playerIsDamaged = isDamaged(playerActionStateId); + const playerIsGrabbed = isGrabbed(playerActionStateId); + const playerIsCommandGrabbed = isCommandGrabbed(playerActionStateId); + + // If the player took damage and was put in some kind of stun this frame, either // start a conversion or - if (opntIsDamaged || opntIsGrabbed || opntIsCommandGrabbed) { + if (playerIsDamaged || playerIsGrabbed || playerIsCommandGrabbed) { if (!state.conversion) { state.conversion = { - playerIndex: indices.playerIndex, - opponentIndex: indices.opponentIndex, + playerIndex, startFrame: currentFrameNumber, endFrame: null, - startPercent: prevOpponentFrame ? prevOpponentFrame.percent ?? 0 : 0, - currentPercent: opponentFrame.percent ?? 0, + startPercent: prevPlayerFrame ? prevPlayerFrame.percent ?? 0 : 0, + currentPercent: playerFrame.percent ?? 0, endPercent: null, moves: [], didKill: false, openingType: "unknown", // Will be updated later + lastHitBy: null, }; conversions.push(state.conversion); } - if (opntDamageTaken) { + const playerDamageTaken = prevPlayerFrame ? calcDamageTaken(playerFrame, prevPlayerFrame) : 0; + if (playerDamageTaken) { // If animation of last hit has been cleared that means this is a new move. This // prevents counting multiple hits from the same move such as fox's drill + let lastHitBy = playerFrame.lastHitBy ?? playerIndex; + if (playerFrame.lastHitBy === null || playerFrame.lastHitBy > 4) { + lastHitBy = playerIndex; + } + + // Update who hit us last + state.conversion.lastHitBy = lastHitBy; + if (state.lastHitAnimation === null) { state.move = { frame: currentFrameNumber, - moveId: playerFrame.lastAttackLanded!, + moveId: frame.players[lastHitBy]!.post!.lastAttackLanded!, hitCount: 0, damage: 0, + playerIndex: lastHitBy, }; state.conversion.moves.push(state.move); @@ -166,7 +181,7 @@ function handleConversionCompute( if (state.move) { state.move.hitCount += 1; - state.move.damage += opntDamageTaken; + state.move.damage += playerDamageTaken; } // Store previous frame animation to consider the case of a trade, the previous @@ -181,32 +196,32 @@ function handleConversionCompute( return; } - const opntInControl = isInControl(oppActionStateId); - const opntDidLoseStock = prevOpponentFrame && didLoseStock(opponentFrame, prevOpponentFrame); + const playerInControl = isInControl(playerActionStateId); + const playerDidLoseStock = prevPlayerFrame && didLoseStock(playerFrame, prevPlayerFrame); - // Update percent if opponent didn't lose stock - if (!opntDidLoseStock) { - state.conversion.currentPercent = opponentFrame.percent ?? 0; + // Update percent if the player didn't lose stock + if (!playerDidLoseStock) { + state.conversion.currentPercent = playerFrame.percent ?? 0; } - if (opntIsDamaged || opntIsGrabbed || opntIsCommandGrabbed) { - // If opponent got grabbed or damaged, reset the reset counter + if (playerIsDamaged || playerIsGrabbed || playerIsCommandGrabbed) { + // If the player got grabbed or damaged, reset the reset counter state.resetCounter = 0; } - const shouldStartResetCounter = state.resetCounter === 0 && opntInControl; + const shouldStartResetCounter = state.resetCounter === 0 && playerInControl; const shouldContinueResetCounter = state.resetCounter > 0; if (shouldStartResetCounter || shouldContinueResetCounter) { // This will increment the reset timer under the following conditions: - // 1) if we were punishing opponent but they have now entered an actionable state - // 2) if counter has already started counting meaning opponent has entered actionable state + // 1) if the player is being punishing but they have now entered an actionable state + // 2) if counter has already started counting meaning the player has entered actionable state state.resetCounter += 1; } let shouldTerminate = false; - // Termination condition 1 - player kills opponent - if (opntDidLoseStock) { + // Termination condition 1 - player was killed + if (playerDidLoseStock) { state.conversion.didKill = true; shouldTerminate = true; } @@ -219,7 +234,7 @@ function handleConversionCompute( // If conversion should terminate, mark the end states and add it to list if (shouldTerminate) { state.conversion.endFrame = playerFrame.frame; - state.conversion.endPercent = prevOpponentFrame ? prevOpponentFrame.percent ?? 0 : 0; + state.conversion.endPercent = prevPlayerFrame ? prevPlayerFrame.percent ?? 0 : 0; state.conversion = null; state.move = null; diff --git a/src/stats/inputs.ts b/src/stats/inputs.ts index 2f0f9b71..0673f961 100644 --- a/src/stats/inputs.ts +++ b/src/stats/inputs.ts @@ -1,6 +1,5 @@ import _ from "lodash"; -import { PlayerIndexedType } from "./common"; -import { FramesType, FrameEntryType, Frames } from "../types"; +import { FramesType, FrameEntryType, Frames, GameStartType } from "../types"; import { StatComputer } from "./stats"; @@ -18,7 +17,6 @@ enum JoystickRegion { export interface PlayerInput { playerIndex: number; - opponentIndex: number; inputCount: number; joystickInputCount: number; cstickInputCount: number; @@ -27,30 +25,32 @@ export interface PlayerInput { } export class InputComputer implements StatComputer { - private playerPermutations = new Array(); - private state = new Map(); + private playerIndices: number[] = []; + private state = new Map(); - public setPlayerPermutations(playerPermutations: PlayerIndexedType[]): void { - this.playerPermutations = playerPermutations; - this.playerPermutations.forEach((indices) => { + public setup(settings: GameStartType): void { + // Reset the state since it's a new game + this.playerIndices = settings.players.map((p) => p.playerIndex); + this.state = new Map(); + + this.playerIndices.forEach((index) => { const playerState: PlayerInput = { - playerIndex: indices.playerIndex, - opponentIndex: indices.opponentIndex, + playerIndex: index, inputCount: 0, joystickInputCount: 0, cstickInputCount: 0, buttonInputCount: 0, triggerInputCount: 0, }; - this.state.set(indices, playerState); + this.state.set(index, playerState); }); } public processFrame(frame: FrameEntryType, allFrames: FramesType): void { - this.playerPermutations.forEach((indices) => { - const state = this.state.get(indices); + this.playerIndices.forEach((index) => { + const state = this.state.get(index); if (state) { - handleInputCompute(allFrames, state, indices, frame); + handleInputCompute(allFrames, state, index, frame); } }); } @@ -60,16 +60,11 @@ export class InputComputer implements StatComputer { } } -function handleInputCompute( - frames: FramesType, - state: PlayerInput, - indices: PlayerIndexedType, - frame: FrameEntryType, -): void { - const playerFrame = frame.players[indices.playerIndex]!.pre; +function handleInputCompute(frames: FramesType, state: PlayerInput, playerIndex: number, frame: FrameEntryType): void { + const playerFrame = frame.players[playerIndex]!.pre; const currentFrameNumber = playerFrame.frame!; const prevFrameNumber = currentFrameNumber - 1; - const prevPlayerFrame = frames[prevFrameNumber] ? frames[prevFrameNumber].players[indices.playerIndex]!.pre : null; + const prevPlayerFrame = frames[prevFrameNumber] ? frames[prevFrameNumber].players[playerIndex]!.pre : null; if (currentFrameNumber < Frames.FIRST_PLAYABLE || !prevPlayerFrame) { // Don't count inputs until the game actually starts diff --git a/src/stats/overall.ts b/src/stats/overall.ts index 76f716a7..f04506e7 100644 --- a/src/stats/overall.ts +++ b/src/stats/overall.ts @@ -1,5 +1,6 @@ import _ from "lodash"; -import { ConversionType, PlayerIndexedType, StockType, OverallType, RatioType, InputCountsType } from "./common"; +import { GameStartType } from "../types"; +import { ConversionType, StockType, OverallType, RatioType, InputCountsType } from "./common"; import { PlayerInput } from "./inputs"; interface ConversionsByPlayerByOpening { @@ -9,24 +10,24 @@ interface ConversionsByPlayerByOpening { } export function generateOverallStats( - playerIndices: PlayerIndexedType[], + settings: GameStartType, inputs: PlayerInput[], stocks: StockType[], conversions: ConversionType[], playableFrameCount: number, ): OverallType[] { const inputsByPlayer = _.keyBy(inputs, "playerIndex"); - const stocksByPlayer = _.groupBy(stocks, "playerIndex"); - const conversionsByPlayer = _.groupBy(conversions, "playerIndex"); + const originalConversions = conversions; + const conversionsByPlayer = _.groupBy(conversions, (conv) => conv.moves[0]?.playerIndex); const conversionsByPlayerByOpening: ConversionsByPlayerByOpening = _.mapValues(conversionsByPlayer, (conversions) => _.groupBy(conversions, "openingType"), ); const gameMinutes = playableFrameCount / 3600; - const overall = playerIndices.map((indices) => { - const playerIndex = indices.playerIndex; - const opponentIndex = indices.opponentIndex; + const overall = settings.players.map((player) => { + const playerIndex = player.playerIndex; + const playerInputs = _.get(inputsByPlayer, playerIndex) || {}; const inputCounts: InputCountsType = { buttons: _.get(playerInputs, "buttonInputCount"), @@ -35,20 +36,45 @@ export function generateOverallStats( joystick: _.get(playerInputs, "joystickInputCount"), total: _.get(playerInputs, "inputCount"), }; - const conversions = _.get(conversionsByPlayer, playerIndex) || []; - const successfulConversions = conversions.filter((conversion) => conversion.moves.length > 1); - const opponentStocks = _.get(stocksByPlayer, opponentIndex) || []; - const opponentEndedStocks = _.filter(opponentStocks, "endFrame"); - - const conversionCount = conversions.length; - const successfulConversionCount = successfulConversions.length; - const totalDamage = - _.sumBy(conversions, (conversion) => conversion.moves.reduce((total, move) => total + move.damage, 0)) || 0; - const killCount = opponentEndedStocks.length; + // const conversions = _.get(conversionsByPlayer, playerIndex) || []; + // const successfulConversions = conversions.filter((conversion) => conversion.moves.length > 1); + let conversionCount = 0; + let successfulConversionCount = 0; + + const opponentIndices = settings.players + .filter((opp) => { + // We want players which aren't ourselves + if (opp.playerIndex === playerIndex) { + return false; + } + + // Make sure they're not on our team either + return !settings.isTeams || opp.teamId !== player.teamId; + }) + .map((opp) => opp.playerIndex); + + let totalDamage = 0; + let killCount = 0; + + // These are the conversions that we did on our opponents + // const opponentStocks = _.get(conversionsByPlayer, playerIndex) || []; + // const opponentEndedStocks = _.filter(opponentStocks, "endFrame"); + + originalConversions.reduce((accum, conversion) => { + if (conversion.playerIndex === playerIndex) return totalDamage; + conversionCount++; + if (conversion.moves.length > 1) successfulConversionCount++; + conversion.moves.forEach((move) => { + totalDamage += move.damage; + if (conversion.didKill && conversion.lastHitBy === playerIndex) { + killCount += 1; + } + }); + return accum; + }, 0); return { playerIndex: playerIndex, - opponentIndex: opponentIndex, inputCounts: inputCounts, conversionCount: conversionCount, totalDamage: totalDamage, @@ -59,9 +85,9 @@ export function generateOverallStats( digitalInputsPerMinute: getRatio(inputCounts.buttons, gameMinutes), openingsPerKill: getRatio(conversionCount, killCount), damagePerOpening: getRatio(totalDamage, conversionCount), - neutralWinRatio: getOpeningRatio(conversionsByPlayerByOpening, playerIndex, opponentIndex, "neutral-win"), - counterHitRatio: getOpeningRatio(conversionsByPlayerByOpening, playerIndex, opponentIndex, "counter-attack"), - beneficialTradeRatio: getBeneficialTradeRatio(conversionsByPlayerByOpening, playerIndex, opponentIndex), + neutralWinRatio: getOpeningRatio(conversionsByPlayerByOpening, playerIndex, opponentIndices, "neutral-win"), + counterHitRatio: getOpeningRatio(conversionsByPlayerByOpening, playerIndex, opponentIndices, "counter-attack"), + beneficialTradeRatio: getBeneficialTradeRatio(conversionsByPlayerByOpening, playerIndex, opponentIndices), }; }); @@ -79,12 +105,14 @@ function getRatio(count: number, total: number): RatioType { function getOpeningRatio( conversionsByPlayerByOpening: ConversionsByPlayerByOpening, playerIndex: number, - opponentIndex: number, + opponentIndices: number[], type: string, ): RatioType { const openings = _.get(conversionsByPlayerByOpening, [playerIndex, type]) || []; - const opponentOpenings = _.get(conversionsByPlayerByOpening, [opponentIndex, type]) || []; + const opponentOpenings = _.flatten( + opponentIndices.map((opponentIndex) => _.get(conversionsByPlayerByOpening, [opponentIndex, type]) || []), + ); return getRatio(openings.length, openings.length + opponentOpenings.length); } @@ -92,10 +120,12 @@ function getOpeningRatio( function getBeneficialTradeRatio( conversionsByPlayerByOpening: ConversionsByPlayerByOpening, playerIndex: number, - opponentIndex: number, + opponentIndices: number[], ): RatioType { const playerTrades = _.get(conversionsByPlayerByOpening, [playerIndex, "trade"]) || []; - const opponentTrades = _.get(conversionsByPlayerByOpening, [opponentIndex, "trade"]) || []; + const opponentTrades = _.flatten( + opponentIndices.map((opponentIndex) => _.get(conversionsByPlayerByOpening, [opponentIndex, "trade"]) || []), + ); const benefitsPlayer = []; @@ -104,13 +134,15 @@ function getBeneficialTradeRatio( zippedTrades.forEach((conversionPair) => { const playerConversion = _.first(conversionPair); const opponentConversion = _.last(conversionPair); - const playerDamage = playerConversion!.currentPercent - playerConversion!.startPercent; - const opponentDamage = opponentConversion!.currentPercent - opponentConversion!.startPercent; - - if (playerConversion!.didKill && !opponentConversion!.didKill) { - benefitsPlayer.push(playerConversion); - } else if (playerDamage > opponentDamage) { - benefitsPlayer.push(playerConversion); + if (playerConversion && opponentConversion) { + const playerDamage = playerConversion.currentPercent - playerConversion.startPercent; + const opponentDamage = opponentConversion.currentPercent - opponentConversion.startPercent; + + if (playerConversion!.didKill && !opponentConversion!.didKill) { + benefitsPlayer.push(playerConversion); + } else if (playerDamage > opponentDamage) { + benefitsPlayer.push(playerConversion); + } } }); diff --git a/src/stats/stats.ts b/src/stats/stats.ts index b035906e..c254326d 100644 --- a/src/stats/stats.ts +++ b/src/stats/stats.ts @@ -1,10 +1,9 @@ import _ from "lodash"; -import { PlayerIndexedType } from "./common"; -import { FrameEntryType, Frames, FramesType } from "../types"; +import { FrameEntryType, Frames, FramesType, GameStartType } from "../types"; export interface StatComputer { - setPlayerPermutations(indices: PlayerIndexedType[]): void; + setup(settings: GameStartType): void; processFrame(newFrame: FrameEntryType, allFrames: FramesType): void; fetch(): T; } @@ -21,16 +20,23 @@ export class Stats { private options: StatOptions; private lastProcessedFrame: number | null = null; private frames: FramesType = {}; - private playerPermutations = new Array(); + private players: number[] = []; private allComputers = new Array>(); public constructor(options?: StatOptions) { this.options = Object.assign({}, defaultOptions, options); } - public setPlayerPermutations(indices: PlayerIndexedType[]): void { - this.playerPermutations = indices; - this.allComputers.forEach((comp) => comp.setPlayerPermutations(indices)); + /** + * Should reset the frames to their default values. + */ + public setup(settings: GameStartType): void { + // Reset the frames since it's a new game + this.frames = {}; + this.players = settings.players.map((v) => v.playerIndex); + + // Forward the settings on to the individual stat computer + this.allComputers.forEach((comp) => comp.setup(settings)); } public register(...computer: StatComputer[]): void { @@ -38,14 +44,15 @@ export class Stats { } public process(): void { - if (this.playerPermutations.length === 0) { + if (this.players.length === 0) { return; } + let i = this.lastProcessedFrame !== null ? this.lastProcessedFrame + 1 : Frames.FIRST; while (this.frames[i]) { const frame = this.frames[i]; // Don't attempt to compute stats on frames that have not been fully received - if (!isCompletedFrame(this.playerPermutations, frame)) { + if (!isCompletedFrame(this.players, frame)) { return; } this.allComputers.forEach((comp) => comp.processFrame(frame, this.frames)); @@ -63,14 +70,17 @@ export class Stats { } } -function isCompletedFrame(playerPermutations: PlayerIndexedType[], frame: FrameEntryType): boolean { +function isCompletedFrame(players: number[], frame: FrameEntryType): boolean { // This function checks whether we have successfully received an entire frame. // It is not perfect because it does not wait for follower frames. Fortunately, // follower frames are not used for any stat calculations so this doesn't matter // for our purposes. - const { playerIndex, opponentIndex } = _.first(playerPermutations)!; - const playerPostFrame = _.get(frame, ["players", playerIndex, "post"]); - const oppPostFrame = _.get(frame, ["players", opponentIndex, "post"]); + for (const player of players) { + const playerPostFrame = _.get(frame, ["players", player, "post"]); + if (!playerPostFrame) { + return false; + } + } - return Boolean(playerPostFrame && oppPostFrame); + return true; } diff --git a/src/stats/stocks.ts b/src/stats/stocks.ts index 80904a1f..f3a1e999 100644 --- a/src/stats/stocks.ts +++ b/src/stats/stocks.ts @@ -1,7 +1,7 @@ import _ from "lodash"; -import { isDead, didLoseStock, PlayerIndexedType, StockType } from "./common"; -import { FrameEntryType, FramesType } from "../types"; +import { isDead, didLoseStock, StockType } from "./common"; +import { FrameEntryType, FramesType, GameStartType } from "../types"; import { StatComputer } from "./stats"; interface StockState { @@ -9,25 +9,29 @@ interface StockState { } export class StockComputer implements StatComputer { - private state = new Map(); - private playerPermutations = new Array(); - private stocks = new Array(); + private state = new Map(); + private playerIndices: number[] = []; + private stocks: StockType[] = []; - public setPlayerPermutations(playerPermutations: PlayerIndexedType[]): void { - this.playerPermutations = playerPermutations; - this.playerPermutations.forEach((indices) => { + public setup(settings: GameStartType): void { + // Reset the state since it's a new game + this.state = new Map(); + this.playerIndices = settings.players.map((p) => p.playerIndex); + this.stocks = []; + + this.playerIndices.forEach((index) => { const playerState: StockState = { stock: null, }; - this.state.set(indices, playerState); + this.state.set(index, playerState); }); } public processFrame(frame: FrameEntryType, allFrames: FramesType): void { - this.playerPermutations.forEach((indices) => { - const state = this.state.get(indices); + this.playerIndices.forEach((index) => { + const state = this.state.get(index); if (state) { - handleStockCompute(allFrames, state, indices, frame, this.stocks); + handleStockCompute(allFrames, state, index, frame, this.stocks); } }); } @@ -40,14 +44,14 @@ export class StockComputer implements StatComputer { function handleStockCompute( frames: FramesType, state: StockState, - indices: PlayerIndexedType, + playerIndex: number, frame: FrameEntryType, stocks: StockType[], ): void { - const playerFrame = frame.players[indices.playerIndex]!.post; + const playerFrame = frame.players[playerIndex]!.post; const currentFrameNumber = playerFrame.frame!; const prevFrameNumber = currentFrameNumber - 1; - const prevPlayerFrame = frames[prevFrameNumber] ? frames[prevFrameNumber].players[indices.playerIndex]!.post : null; + const prevPlayerFrame = frames[prevFrameNumber] ? frames[prevFrameNumber].players[playerIndex]!.post : null; // If there is currently no active stock, wait until the player is no longer spawning. // Once the player is no longer spawning, start the stock @@ -58,8 +62,7 @@ function handleStockCompute( } state.stock = { - playerIndex: indices.playerIndex, - opponentIndex: indices.opponentIndex, + playerIndex, startFrame: currentFrameNumber, endFrame: null, startPercent: 0, diff --git a/test/conversion.spec.ts b/test/conversion.spec.ts index 07c8bc6a..bf2cafaf 100644 --- a/test/conversion.spec.ts +++ b/test/conversion.spec.ts @@ -7,7 +7,7 @@ describe("when calculating conversions", () => { const puff = stats.overall[0]; let totalDamagePuffDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === puff.playerIndex) { + if (conversion.lastHitBy === puff.playerIndex) { totalDamagePuffDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -22,7 +22,7 @@ describe("when calculating conversions", () => { const bowser = stats.overall[0]; let totalDamageBowserDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === bowser.playerIndex) { + if (conversion.lastHitBy === bowser.playerIndex) { totalDamageBowserDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -37,7 +37,7 @@ describe("when calculating conversions", () => { const falcon = stats.overall[0]; let totalDamageFalconDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === falcon.playerIndex) { + if (conversion.lastHitBy === falcon.playerIndex) { totalDamageFalconDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -52,7 +52,7 @@ describe("when calculating conversions", () => { const ganon = stats.overall[0]; let totalDamageGanonDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === ganon.playerIndex) { + if (conversion.lastHitBy === ganon.playerIndex) { totalDamageGanonDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -67,7 +67,7 @@ describe("when calculating conversions", () => { const kirby = stats.overall[0]; let totalDamageKirbyDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === kirby.playerIndex) { + if (conversion.lastHitBy === kirby.playerIndex) { totalDamageKirbyDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -82,7 +82,7 @@ describe("when calculating conversions", () => { const yoshi = stats.overall[0]; let totalDamageYoshiDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === yoshi.playerIndex) { + if (conversion.lastHitBy === yoshi.playerIndex) { totalDamageYoshiDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -97,7 +97,7 @@ describe("when calculating conversions", () => { const mewTwo = stats.overall[0]; let totalDamageMewTwoDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === mewTwo.playerIndex) { + if (conversion.lastHitBy === mewTwo.playerIndex) { totalDamageMewTwoDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); diff --git a/test/stats.spec.ts b/test/stats.spec.ts index fd060383..d31377a6 100644 --- a/test/stats.spec.ts +++ b/test/stats.spec.ts @@ -1,4 +1,5 @@ import { SlippiGame } from "../src"; +import { didLoseStock } from "../src/stats/common"; describe("when calculating stats", () => { it("should correctly calculate L cancel counts", () => { @@ -22,7 +23,7 @@ describe("when calculating stats", () => { const yl = stats.overall[1]; let totalDamagePuffDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === puff.playerIndex) { + if (conversion.lastHitBy === puff.playerIndex) { totalDamagePuffDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -42,15 +43,11 @@ describe("when calculating stats", () => { let totalDamagePichuDealt = 0; let icsDamageDealt = 0; stats.conversions.forEach((conversion) => { - switch (conversion.playerIndex) { - case pichu.playerIndex: { - totalDamagePichuDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); - break; - } - case ics.playerIndex: { - icsDamageDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); - break; - } + if (conversion.playerIndex === pichu.playerIndex) { + icsDamageDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); + } + if (conversion.playerIndex === ics.playerIndex) { + totalDamagePichuDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); expect(totalDamagePichuDealt).toBe(pichu.totalDamage); @@ -70,10 +67,10 @@ describe("when calculating stats", () => { let totalDamageNessDealt = 0; let totalDamageFoxDealt = 0; stats.conversions.forEach((conversion) => { - if (conversion.playerIndex === ness.playerIndex) { + if (conversion.lastHitBy === ness.playerIndex) { totalDamageNessDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } - if (conversion.playerIndex === fox.playerIndex) { + if (conversion.lastHitBy === fox.playerIndex) { totalDamageFoxDealt += conversion.moves.reduce((total, move) => total + move.damage, 0); } }); @@ -87,3 +84,9 @@ describe("when calculating stats", () => { }); }); }); + +describe("when using common functions", () => { + it("should return false if required", () => { + expect(didLoseStock(undefined, undefined)).toEqual(false); + }); +});