Skip to content

Commit

Permalink
Add Game End Placements (#106)
Browse files Browse the repository at this point in the history
* + Add PlacementType to game end
+ Add placements to OverallStats
+ Add GetWinners method to lib
+ Add specs

* fix: adjust to spec changes

* fix: update tests

* ensure non-null playerIndex

* prefer empty list over null

* use object shorthand notation

* update tests

* prefer empty list for placements

* update tests

* move old slp placings test

* simplify function

* update tests again

* placements cant be null anymore

* simplify function

Co-authored-by: Nikhil Narayana <nikhil.narayana@live.com>
Co-authored-by: Vince Au <vince@canva.com>
  • Loading branch information
3 people committed Oct 16, 2022
1 parent 8faac98 commit d78c497
Show file tree
Hide file tree
Showing 11 changed files with 219 additions and 47 deletions.
Binary file added slp/placementsTest/ffa_1p2p3p_winner_3p.slp
Binary file not shown.
Binary file added slp/placementsTest/ffa_1p2p4p_winner_4p.slp
Binary file not shown.
Binary file added slp/placementsTest/ffa_1p2p_winner_2p.slp
Binary file not shown.
Binary file not shown.
Binary file not shown.
37 changes: 33 additions & 4 deletions src/SlippiGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Stats,
StockComputer,
} from "./stats";
// Type imports
import type {
EventCallbackFunc,
FrameEntryType,
Expand All @@ -17,6 +16,7 @@ import type {
GameStartType,
GeckoListType,
MetadataType,
PlacementType,
RollbackFrames,
} from "./types";
import { SlpParser, SlpParserEvent } from "./utils/slpParser";
Expand Down Expand Up @@ -155,18 +155,21 @@ export class SlippiGame {
const playableFrameCount = this.parser.getPlayableFrameCount();
const overall = generateOverallStats({ settings, inputs, conversions, playableFrameCount });

const stats = {
const gameEnd = this.parser.getGameEnd();
const gameComplete = gameEnd !== null;

const stats: StatsType = {
lastFrame: this.parser.getLatestFrameNumber(),
playableFrameCount,
stocks: stocks,
conversions: conversions,
combos: this.comboComputer.fetch(),
actionCounts: this.actionsComputer.fetch(),
overall: overall,
gameComplete: this.parser.getGameEnd() !== null,
gameComplete,
};

if (this.parser.getGameEnd() !== null) {
if (gameComplete) {
// If the game is complete, store a cached version of stats because it should not
// change anymore. Ideally the statsCompuer.process and fetch functions would simply do no
// work in this case instead but currently the conversions fetch function,
Expand Down Expand Up @@ -194,4 +197,30 @@ export class SlippiGame {

return this.input.filePath ?? null;
}

public getWinners(): PlacementType[] {
this._process();

const gameEnd = this.getGameEnd();
if (!gameEnd) {
return [];
}

const placements = gameEnd.placements;
const firstPosition = placements.find((placement) => placement?.position === 0);
if (!firstPosition) {
return [];
}

const settings = this.getSettings();
if (settings?.isTeams) {
const winningTeam = settings.players.find(({ playerIndex }) => playerIndex === firstPosition.playerIndex)?.teamId;
return placements.filter((placement) => {
const teamId = settings.players.find(({ playerIndex }) => playerIndex === placement.playerIndex)?.teamId;
return teamId === winningTeam;
});
}

return [firstPosition];
}
}
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ export interface FrameBookendType {
export interface GameEndType {
gameEndMethod: number | null;
lrasInitiatorIndex: number | null;
placements: PlacementType[];
}

export interface PlacementType {
playerIndex: number;
position: number | null;
}

export interface GeckoListType {
Expand Down
7 changes: 7 additions & 0 deletions src/utils/slpReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
EventPayloadTypes,
GeckoCodeType,
MetadataType,
PlacementType,
PlayerType,
SelfInducedSpeedsType,
} from "../types";
Expand Down Expand Up @@ -453,9 +454,15 @@ export function parseMessage(command: Command, payload: Uint8Array): EventPayloa
latestFinalizedFrame: readInt32(view, 0x5),
};
case Command.GAME_END:
const placements = [0, 1, 2, 3].map((playerIndex): PlacementType => {
const position = readInt8(view, 0x3 + playerIndex);
return { playerIndex, position };
});

return {
gameEndMethod: readUint8(view, 0x1),
lrasInitiatorIndex: readInt8(view, 0x2),
placements,
};
case Command.GECKO_LIST:
const codes: GeckoCodeType[] = [];
Expand Down
56 changes: 28 additions & 28 deletions test/game.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@ import { SlippiGame } from "../src";

it("should correctly return game settings", () => {
const game = new SlippiGame("slp/sheik_vs_ics_yoshis.slp");
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.stageId).toBe(8);
expect(_.first(settings.players).characterId).toBe(0x13);
expect(_.last(settings.players).characterId).toBe(0xe);
expect(_.first(settings.players)?.characterId).toBe(0x13);
expect(_.last(settings.players)?.characterId).toBe(0xe);
expect(settings.slpVersion).toBe("0.1.0");
});

it("should correctly return stats", () => {
const game = new SlippiGame("slp/test.slp");
const stats = game.getStats();
const stats = game.getStats()!;
expect(stats.lastFrame).toBe(3694);

// Test stocks
// console.log(stats);
expect(stats.stocks.length).toBe(5);
expect(_.last(stats.stocks).endFrame).toBe(3694);
expect(_.last(stats.stocks)?.endFrame).toBe(3694);

// Test conversions
// console.log(stats.events.punishes);
expect(stats.conversions.length).toBe(10);
const firstConversion = _.first(stats.conversions);
const firstConversion = _.first(stats.conversions)!;
expect(firstConversion.moves.length).toBe(4);
expect(_.first(firstConversion.moves).moveId).toBe(15);
expect(_.last(firstConversion.moves).moveId).toBe(17);
expect(_.first(firstConversion.moves)?.moveId).toBe(15);
expect(_.last(firstConversion.moves)?.moveId).toBe(17);

// Test action counts
expect(stats.actionCounts[0].wavedashCount).toBe(16);
Expand All @@ -47,7 +47,7 @@ it("should correctly return stats", () => {

it("should correctly return metadata", () => {
const game = new SlippiGame("slp/test.slp");
const metadata = game.getMetadata();
const metadata = game.getMetadata()!;
expect(metadata.startAt).toBe("2017-12-18T21:14:14Z");
expect(metadata.playedOn).toBe("dolphin");
});
Expand All @@ -62,55 +62,55 @@ it("should correctly return file path", () => {

it("should be able to read incomplete SLP files", () => {
const game = new SlippiGame("slp/incomplete.slp");
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.players.length).toBe(2);
game.getMetadata();
game.getStats();
});

it("should be able to read nametags", () => {
const game = new SlippiGame("slp/nametags.slp");
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.players[0].nametag).toBe("AMNイ");
expect(settings.players[1].nametag).toBe("");

const game2 = new SlippiGame("slp/nametags2.slp");
const settings2 = game2.getSettings();
const settings2 = game2.getSettings()!;
expect(settings2.players[0].nametag).toBe("A1=$");
expect(settings2.players[1].nametag).toBe("か、9@");

const game3 = new SlippiGame("slp/nametags3.slp");
const settings3 = game3.getSettings();
const settings3 = game3.getSettings()!;
expect(settings3.players[0].nametag).toBe("B R");
expect(settings3.players[1].nametag).toBe(". 。");
});

it("should be able to read netplay names and codes", () => {
const game = new SlippiGame("slp/finalizedFrame.slp");
const metadata = game.getMetadata();
expect(metadata.players[0].names.netplay).toBe("V");
expect(metadata.players[0].names.code).toBe("VA#0");
expect(metadata.players[1].names.netplay).toBe("Fizzi");
expect(metadata.players[1].names.code).toBe("FIZZI#36");
const players = game.getMetadata()!.players!;
expect(players[0].names!.netplay).toBe("V");
expect(players[0].names!.code).toBe("VA#0");
expect(players[1].names!.netplay).toBe("Fizzi");
expect(players[1].names!.code).toBe("FIZZI#36");
});

it("should be able to read console nickname", () => {
const game = new SlippiGame("slp/realtimeTest.slp");
const nickName = game.getMetadata().consoleNick;
const nickName = game.getMetadata()?.consoleNick;
expect(nickName).toBe("Day 1");
});

it("should support PAL version", () => {
const palGame = new SlippiGame("slp/pal.slp");
const ntscGame = new SlippiGame("slp/ntsc.slp");

expect(palGame.getSettings().isPAL).toBe(true);
expect(ntscGame.getSettings().isPAL).toBe(false);
expect(palGame.getSettings()?.isPAL).toBe(true);
expect(ntscGame.getSettings()?.isPAL).toBe(false);
});

it("should correctly distinguish between different controller fixes", () => {
const game = new SlippiGame("slp/controllerFixes.slp");
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.players[0].controllerFix).toBe("Dween");
expect(settings.players[1].controllerFix).toBe("UCF");
expect(settings.players[2].controllerFix).toBe("None");
Expand All @@ -119,20 +119,20 @@ it("should correctly distinguish between different controller fixes", () => {
it("should be able to support reading from a buffer input", () => {
const buf = fs.readFileSync("slp/sheik_vs_ics_yoshis.slp");
const game = new SlippiGame(buf);
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.stageId).toBe(8);
expect(_.first(settings.players).characterId).toBe(0x13);
expect(_.last(settings.players).characterId).toBe(0xe);
expect(_.first(settings.players)?.characterId).toBe(0x13);
expect(_.last(settings.players)?.characterId).toBe(0xe);
});

it("should be able to support reading from an array buffer input", () => {
const buf = fs.readFileSync("slp/sheik_vs_ics_yoshis.slp");
const arrayBuf = buf.buffer;
const game = new SlippiGame(arrayBuf);
const settings = game.getSettings();
const settings = game.getSettings()!;
expect(settings.stageId).toBe(8);
expect(_.first(settings.players).characterId).toBe(0x13);
expect(_.last(settings.players).characterId).toBe(0xe);
expect(_.first(settings.players)?.characterId).toBe(0x13);
expect(_.last(settings.players)?.characterId).toBe(0xe);
});

it("should extract gecko list", () => {
Expand Down
130 changes: 130 additions & 0 deletions test/placings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import _ from "lodash";

import { SlippiGame } from "../src";

describe("when determining placings", () => {
it("should return empty placings for older slp files", () => {
const game = new SlippiGame("slp/test.slp");
const placements = game.getGameEnd()!.placements!;
// Test Placements
expect(placements).toHaveLength(4);
// Expect empty placements
expect(placements[0].position).toBe(null);
expect(placements[1].position).toBe(null);
expect(placements[2].position).toBe(null);
expect(placements[3].position).toBe(null);
});

describe("when the game mode is Free for All", () => {
it("should find the winner", () => {
const game = new SlippiGame("slp/placementsTest/ffa_1p2p_winner_2p.slp");
const winners = game.getWinners();
expect(winners).toHaveLength(1);
expect(winners[0].playerIndex).toBe(1);
expect(winners[0].position).toBe(0);
});

it("should return placings for 2 player games", () => {
const game = new SlippiGame("slp/placementsTest/ffa_1p2p_winner_2p.slp");
const placements = game.getGameEnd()!.placements!;
expect(placements).toHaveLength(4);
expect(placements[0].position).toBe(1); // player in port 1 is on second place
expect(placements[0].playerIndex).toBe(0);
expect(placements[1].position).toBe(0); // player in port 2 is on first place
expect(placements[1].playerIndex).toBe(1);
});

it("should return placings for 3 player games", () => {
let game = new SlippiGame("slp/placementsTest/ffa_1p2p3p_winner_3p.slp");
let placements = game.getGameEnd()?.placements!;
expect(placements).toBeDefined();
expect(placements).toHaveLength(4);

expect(placements[0].playerIndex).toBe(0);
expect(placements[1].playerIndex).toBe(1);
expect(placements[2].playerIndex).toBe(2);
expect(placements[3].playerIndex).toBe(3);

expect(placements[0].position).toBe(1); // Expect player 1 to be on second place
expect(placements[1].position).toBe(2); // Expect player 2 to be on third place
expect(placements[2].position).toBe(0); // Expect player 3 to be first place
expect(placements[3].position).toBe(-1); // Expect player 4 to not be present

game = new SlippiGame("slp/placementsTest/ffa_1p2p4p_winner_4p.slp");
placements = game.getGameEnd()?.placements!;
expect(placements).toBeDefined();
expect(placements).toHaveLength(4);

expect(placements[0].playerIndex).toBe(0);
expect(placements[1].playerIndex).toBe(1);
expect(placements[2].playerIndex).toBe(2);
expect(placements[3].playerIndex).toBe(3);

expect(placements[0].position).toBe(1); // Expect player 1 to be on second place
expect(placements[1].position).toBe(2); // Expect player 2 to be on third place
expect(placements[2].position).toBe(-1); // Expect player 3 to not be present
expect(placements[3].position).toBe(0); // Expect player 4 to be first place
});
});

describe("when the game mode is Teams", () => {
it("should return all winners", () => {
const game = new SlippiGame("slp/placementsTest/teams_time_p3_redVSp1p2_blueVSp4_green_winner_blue.slp");
const settings = game.getSettings()!;
const winners = game.getWinners();
expect(winners).toHaveLength(2);
expect(winners[0].playerIndex).toBe(0);
expect(winners[0].position).toBe(1);
expect(settings.players[0]?.teamId).toBe(1);
expect(winners[1].playerIndex).toBe(1);
expect(winners[1].position).toBe(0);
expect(settings.players[1].teamId).toBe(1);
});

it("should return the correct placings", () => {
const game = new SlippiGame("slp/placementsTest/teams_p1p2_blueVSp4_green_winner_green.slp");
const settings = game.getSettings()!;
const placements = game.getGameEnd()?.placements!;
expect(placements).toBeDefined();
expect(placements).toHaveLength(4);

expect(placements[0].playerIndex).toBe(0);
expect(placements[1].playerIndex).toBe(1);
expect(placements[2].playerIndex).toBe(2);
expect(placements[3].playerIndex).toBe(3);

expect(placements[0].position).toBe(1); // Expect player 1 to be on second place
expect(placements[1].position).toBe(2); // Expect player 2 to be on third place
expect(placements[2].position).toBe(-1); // Expect player 3 to not be present
expect(placements[3].position).toBe(0); // Expect player 4 to be first place

expect(settings.players[0].teamId).toBe(1); // Expect player 1 to be on team blue
expect(settings.players[1].teamId).toBe(1); // Expect player 2 to be on team blue
expect(settings.players[2].teamId).toBe(2); // Expect player 4 to be on team green
});

it("should return placings in timed mode", () => {
// Based on scores (time), not stock
const game = new SlippiGame("slp/placementsTest/teams_time_p3_redVSp1p2_blueVSp4_green_winner_blue.slp");
const settings = game.getSettings()!;
const placements = game.getGameEnd()?.placements!;
expect(placements).toBeDefined();
expect(placements).toHaveLength(4);

expect(placements[0].playerIndex).toBe(0);
expect(placements[1].playerIndex).toBe(1);
expect(placements[2].playerIndex).toBe(2);
expect(placements[3].playerIndex).toBe(3);

expect(placements[0].position).toBe(1); // Expect player 1 to be on second place
expect(placements[1].position).toBe(0); // Expect player 2 to be on first place
expect(placements[2].position).toBe(3); // Expect player 3 to be on fourth place
expect(placements[3].position).toBe(2); // Expect player 4 to be on third place

expect(settings.players[0].teamId).toBe(1); // Expect player 1 to be on team blue
expect(settings.players[1].teamId).toBe(1); // Expect player 2 to be on team blue
expect(settings.players[2].teamId).toBe(0); // Expect player 3 to be on team red
expect(settings.players[3].teamId).toBe(2); // Expect player 4 to be on team green
});
});
});
Loading

0 comments on commit d78c497

Please sign in to comment.