Skip to content

Instantly share code, notes, and snippets.

@IvantheTricourne
Last active May 30, 2020 00:34
Show Gist options
  • Save IvantheTricourne/96ebf55ae7023d307f0c1a140885b05b to your computer and use it in GitHub Desktop.
Save IvantheTricourne/96ebf55ae7023d307f0c1a140885b05b to your computer and use it in GitHub Desktop.
See first comment for usage/installation instructions.
// slippi deps
const fs = require('fs');
const _ = require('lodash');
const path = require('path');
const slp = require('slp-parser-js');
const SlippiGame = slp.default; // npm install slp-parser-js
// cli deps
const yargs = require('yargs'); // npm install yargs
/////////////////////////////////////////////////////////////////
// CLI
/////////////////////////////////////////////////////////////////
let todayDate = new Date();
const defaultOutputFileDateStr=`${todayDate.getFullYear()}-${todayDate.getMonth() + 1}-${todayDate.getDate()}`;
const argv = yargs
.scriptName("getGamesOnDate")
.usage('$0 <cmd> [args]')
.command('all', "Get all games in directory; default command", {})
.command('today', "Get today's games", {})
.command('on', 'Get games on a specific date', {
date: {
description: 'Date to find games on',
alias: 'd',
type: 'string',
default: '',
},
})
.command('range', 'Get games within a given date range', {
startDate: {
description: 'Date to start range (inclusive)',
alias: 's',
type: 'string',
default: '',
},
endDate: {
description: 'Date to end range (exclusive)',
alias: 'e',
type: 'string',
default: '',
}
})
// options for generating description
.option('title', {
alias: 't',
description: 'Generate focus title for VOD description',
type: 'string',
default: 'FoxInTheB0XX' // @NOTE: change this to desired default if you don't want to set it manually
})
.option('type', {
alias: 's',
description: 'Generate sub focus title for VOD description',
type: 'string',
default: 'SmashLadder Ranked' // @NOTE: change this to desired default if don't want to set it manually
})
// options for filtering bad games (i.e., handwarmers)
.option('minGameLength', {
alias: 'l',
description: 'Minimum game length (secs) to include',
type: 'number',
default: 60
})
.option('minKills', {
alias: 'k',
description: 'Minimum game kill-count to include',
type: 'number',
default: 3
})
// input/output options
.option('dir', {
alias: 'i',
description: 'Slippi directory to use (relative to script)',
type: 'string',
default: './Slippi'
})
.option('dolphin', {
description: 'Set output filename for dolphin replay file',
type: 'string',
default: `./${defaultOutputFileDateStr}-replay.json`
})
.option('timestamp', {
description: 'Set output filename for timestamp file',
type: 'string',
default: `./${defaultOutputFileDateStr}-vod-info.txt`
})
// by character filtering
.option('character', {
alias: 'c',
description: 'Include all games with character (use short name)',
type: 'string'
})
.option('characters', {
description: 'Include games with characters (use short name; empty = all)',
type: 'array',
default: []
})
.option('excludeDittos', {
description: 'Exclude games with dittos',
type: 'boolean',
default: false
})
.help()
.alias('help', 'h')
.argv;
// help filter out handwarmers
var minGameLengthSeconds = argv.minGameLength;
var minKills = argv.minKills;
// @TODO: timestamp type representing max win count in a set
// 2 for Bo3
// 3 for Bo5
// anything else for character timestamping
var timestampType = 0;
// slippi directory
const basePath = path.join(__dirname, argv.dir);
// output file names
const dolphinOutputFileName = argv.dolphin;
const VODTimestampFileName = argv.timestamp;
// optional description stuff
var focusName = argv.title;
var matchType = argv.type;;
fs.writeFileSync(VODTimestampFileName, "");
if (focusName !== '' && matchType !== '') {
fs.appendFileSync(VODTimestampFileName, `${focusName} - ${matchType} matches\n\n`);
}
fs.appendFileSync(VODTimestampFileName, `Timestamps autogenerated via: "https://gist.github.com/IvantheTricourne/96ebf55ae7023d307f0c1a140885b05b"\n\n`);
// Date values for filtering slp dir
var start = '';
var end = '';
if (argv._.includes('today')) {
start = new Date().toLocaleDateString();
console.log(`Looking for today's (${start}) games\n`);
} else if (argv._.includes('on')) {
start = argv.date;
let endDate = new Date(start);
end = `${endDate.getFullYear()}/${endDate.getMonth() + 1}/${endDate.getDate() + 1}`;
console.log(`Looking for games on ${start}\n`);
} else if (argv._.includes('range')) {
start = argv.startDate;
end = argv.endDate;
console.log(`Looking for games between ${start} and ${end}\n`);
} else if (argv._.includes('all')) {
console.log(`Using all files in provided Slippi directory\n`);
} else {
// default behavior is to just use all files in a dir
console.log(`Using all files in provided Slippi directory\n`);
}
/////////////////////////////////////////////////////////////////
// Script
/////////////////////////////////////////////////////////////////
// dolphin replay object
const dolphin = {
"mode": "queue",
"replay": "",
"isRealTimeMode": false,
"outputOverlayFiles": true,
"queue": []
};
// allow putting files in folders
function walk(dir) {
let results = [];
let list = fs.readdirSync(dir);
_.each(list, (file) => {
file = path.join(dir, file);
let stat = fs.statSync(file);
if (stat && stat.isDirectory()) {
// Recurse into a subdirectory
results = results.concat(walk(file));
} else if (path.extname(file) === ".slp"){
results.push(file);
}
});
return results;
}
// convert seconds to HH:mm:ss,ms
// from: https://gist.github.com/vankasteelj/74ab7793133f4b257ea3
function sec2time(timeInSeconds) {
var pad = function(num, size) { return ('000' + num).slice(size * -1); },
time = parseFloat(timeInSeconds).toFixed(3),
hours = Math.floor(time / 60 / 60),
minutes = Math.floor(time / 60) % 60,
seconds = Math.floor(time - minutes * 60),
milliseconds = time.slice(-3);
return pad(hours, 2) + ':' + pad(minutes, 2) + ':' + pad(seconds, 2);
}
// within given date range
function isWithinDateRange(start,end,gameDate) {
let startDate = new Date(start);
let endDate = new Date(end);
// if no dates are provided, use all files
// if only one or the other is provided, then use the approp comparison
// otherwise, use the range
if (start === '' || start === null || start === undefined &&
end === '' || end === null || end === undefined) {
return true;
} else if (end === '' || end === null || end === undefined) {
return (startDate.valueOf() <= gameDate.valueOf());
} else if (start === '' || start === null || start === undefined) {
return (gameDate.valueOf() <= endDate.valueOf());
} else {
return (startDate.valueOf() <= gameDate.valueOf() &&
gameDate.valueOf() <= endDate.valueOf());
}
}
// create player info object
function makePlayerInfo(idx, settings, metadata) {
let player = _.get(settings, ["players", idx]);
return {
port: player.port,
tag: player.nametag,
netplayName: _.get(metadata, ["players", idx, "names", "netplay"], null) || "No Name",
characterName: slp.characters.getCharacterShortName(player.characterId),
color: slp.characters.getCharacterColorName(player.characterId, player.characterColor)
};
}
// determine if set of players is the same
function isSamePlayers(currPlayers, newPlayersInfo) {
// return false when players is uninitiated
if (currPlayers === null) {
return false;
}
// @TODO: there's probably a better way to do this
return (currPlayers.player0.characterName === newPlayersInfo.player0.characterName &&
currPlayers.player0.color === newPlayersInfo.player0.color &&
currPlayers.player0.netplayName === newPlayersInfo.player0.netplayName &&
currPlayers.player1.characterName === newPlayersInfo.player1.characterName &&
currPlayers.player1.color === newPlayersInfo.player1.color &&
currPlayers.player1.netplayName === newPlayersInfo.player1.netplayName) ||
(currPlayers.player1.characterName === newPlayersInfo.player0.characterName &&
currPlayers.player1.color === newPlayersInfo.player0.color &&
currPlayers.player1.netplayName === newPlayersInfo.player0.netplayName &&
currPlayers.player0.characterName === newPlayersInfo.player1.characterName &&
currPlayers.player0.color === newPlayersInfo.player1.color &&
currPlayers.player0.netplayName === newPlayersInfo.player1.netplayName);
}
// make versus string from player infos
function makeVersusStringPlayerInfo (playerInfo) {
var info = '';
if (playerInfo.tag !== '' && playerInfo.netplayName !== '') {
info = `${playerInfo.netplayName}/${playerInfo.tag} (P${playerInfo.port},${playerInfo.color} ${playerInfo.characterName})`;
} else if (playerInfo.tag !== '') {
info = `${playerInfo.tag} (P${playerInfo.port},${playerInfo.color} ${playerInfo.characterName})`;
} else if (playerInfo.netplayName !== '') {
info = `${playerInfo.netplayName} (P${playerInfo.port},${playerInfo.color} ${playerInfo.characterName})`;
} else {
info = `No Name (P${playerInfo.port},${playerInfo.color} ${playerInfo.characterName})`;
}
return info;
}
function makeVersusString(currPlayers) {
if (currPlayers === null) {
return '';
}
let player0Info = currPlayers.player0;
let player1Info = currPlayers.player1;
return `${makeVersusStringPlayerInfo(player0Info)} vs ${makeVersusStringPlayerInfo(player1Info)}`;
}
// Write to timestamp file
// @TODO: convert this to a set count
function writeToTimestampFile(currTimestamp, currSetLength, currSetGameCount, currPlayers) {
if (currSetGameCount !== 0) {
console.log(`Timestamp: ${sec2time(currTimestamp)} - ${sec2time(currTimestamp + currSetLength)} (total: ${sec2time(currSetLength)})\n`);
fs.appendFileSync(VODTimestampFileName, `${sec2time(currTimestamp)} ${makeVersusString(currPlayers)}\n`);
} else {
console.log('No timestamp generated. No games included!');
}
}
// main script
function getGames() {
let files = walk(basePath);
console.log(`${files.length} files found in target directory`);
// VOD data report
var badFiles = 0;
var numCPU = 0;
var totalVODLength = 0;
// VOD timestamp info containers
var currPlayers = null;
var currTimestamp = 0;
var currSetLength = 0;
var currSetGameCount = 0;
var player0Info = {};
var player1Info = {};
var stageInfo = '';
_.each(files, (file, i) => {
try {
const game = new SlippiGame(file);
// since it is less intensive to get the settings we do that first
const settings = game.getSettings();
const metadata = game.getMetadata();
const gameDate = new Date(metadata.startAt);
// game date filtering
if (isWithinDateRange(start, end, gameDate)) {
// skip to next file if CPU exists
const cpu = _.some(settings.players, (player) => player.type != 0);
const notsingles = settings.players.length != 2;
if (cpu) {
numCPU++;
return;
} else if (notsingles) {
return;
}
// calculate game length in seconds
const gameLength = metadata.lastFrame / 60;
// padded game length (w/ ready splash + black screen)
// @NOTE: this is an approximation
// @TODO: make this more accurate
let paddedGameLength = gameLength + 4;
// update player+character information
player0Info = makePlayerInfo(0, settings, metadata);
player1Info = makePlayerInfo(1, settings, metadata);
// filter based on character here
if (argv.excludeDittos && (player0Info.characterName === player1Info.characterName)) {
console.log(`File ${i+1} | Game excluded: ${player0Info.characterName} ditto`);
return;
}
if (argv.character !== undefined) {
if (argv.character !== player0Info.characterName && argv.character !== player1Info.characterName) {
console.log(`File ${i+1} | Game excluded: ${argv.character} is not used`);
return;
}
}
if (argv.characters.length !== 0) {
let validChars = argv.characters;
if (argv.character !== undefined) {
validChars.push(argv.character);
}
// remove game if characters don't match desired
if (!validChars.includes(player0Info.characterName)) {
console.log(`File ${i+1} | Game excluded: ${player0Info.characterName} is used`);
return;
}
if (!validChars.includes(player1Info.characterName)) {
console.log(`File ${i+1} | Game excluded: ${player1Info.characterName} is used`);
return;
}
}
// Get stats after filtering is done (bc it slows things down a lot)
// @TODO: determine player win counts
const stats = game.getStats();
// update player tracker info
let newPlayersInfo = {
player0: player0Info,
player1: player1Info
};
if (!(isSamePlayers(currPlayers, newPlayersInfo))) {
// push current info into vod string iff players are already tracked
if (currPlayers !== null) {
writeToTimestampFile(currTimestamp, currSetLength, currSetGameCount, currPlayers);
}
// reset new values
currPlayers = newPlayersInfo;
currTimestamp += currSetLength;
currSetLength = 0;
currSetGameCount = 0;
console.log("____________________________________________________________");
console.log(makeVersusString(currPlayers));
console.log("____________________________________________________________");
}
// filter out short games (i.e., handwarmers) + not enough kills
let totalKills = 0;
_.each(stats.overall, (playerStats, i) => {
totalKills += playerStats.killCount;
});
if (gameLength < minGameLengthSeconds && totalKills < minKills) {
console.log(`File ${i+1} | Game excluded: <${minGameLengthSeconds}secs + <${minKills} kills`);
return;
}
// good game information logging
console.log(`File ${i+1} | Game included: ${sec2time(paddedGameLength)}`);
// create object w/ game info
let gameReplaySpec = {
path: file,
startFrame: -120, // this includes the Ready splash screen
endFrame: metadata.lastFrame,
gameStartAt: _.get(metadata, "startAt", ""),
gameStation: _.get(metadata, "consoleNick", ""),
// attach additional info for lols
additional: {
gameLength: sec2time(paddedGameLength),
stage: slp.stages.getStageName(settings.stageId),
player0: player0Info,
player1: player1Info
}
};
totalVODLength += paddedGameLength;
currSetLength += paddedGameLength;
currSetGameCount += 1;
// push game object to queue
dolphin.queue.push(gameReplaySpec);
}
} catch (err) {
fs.appendFileSync("./log.txt", `${err.stack}\n\n`);
badFiles++;
console.log(`File ${i+1} | ${file} is bad`);
}
});
// push the last set to timestamp file regardless
writeToTimestampFile(currTimestamp, currSetLength, currSetGameCount, currPlayers);
// write dolphin replay object
fs.writeFileSync(dolphinOutputFileName, JSON.stringify(dolphin));
console.log(`${badFiles} bad file(s) ignored`);
console.log(`${numCPU} game(s) with CPUs removed`);
console.log(`${dolphin.queue.length} good game(s) found`);
console.log(`Approximate VOD length: ${sec2time(totalVODLength)}\n`);
}
getGames();
@IvantheTricourne
Copy link
Author

IvantheTricourne commented Feb 27, 2020

Gee, wouldn't it be cool if we could play several Slippi replays back to back? Maybe even record them and make a VOD?

How to use this script

Requirements

  • node
  • Slippi compatible dolphin
  • A directory/folder containing slippi replays

Usage description

Generate files

  1. Create a working directory/folder to put this script in (e.g. slippi-scripts)
  2. Copy this script into that directory.
  3. Open a terminal/command prompt in that directory.
  4. Run npm install slp-parser-js yargs NOTE: After doing this once successfully, you don't need to do it again.
  5. Run node getGamesOnDate.js with proper CLI args/flags (see section CLI usage below)

If all goes well, you should see two generated files in your working directory. By default, these files are named prefixed with the current date (YYYY-MM-DD):

  • YYYY-MM-DD-replay.json - the dolphin output file for dolphin playback
  • YYYY-MM-DD-vod-info.txt - approximate timestamps for generated playback file

Watching + Recording

  1. Launch dolphin while pointing to the generated dolphin output file. See Credits below for more information.
  2. In the pop up dolphin window, click play.
  3. (optional) Make a VOD by recording dolphin playback via OBS studio or frame dumping.

CLI usage

NOTE: With the exception of date filtering options (i.e., on, range, and today), example options can be combined.

Basic Examples

  • Get today's games: node getGamesOnDate today
  • Get games on a specific date: node getGamesOnDate on -d 2020/02/27
  • Get games within a range: node getGamesOnDate range -s 2020/02/22 -e 2020/02/27
  • Get all games: node getGamesOnDate

Character filtering examples

Oh! And it'd be even cooler if we could get games for specific matchups! Like Fox vs. Marth!

  • Get games with Fox on 4/20/2020: node getGamesOnDate on -d 2020/04/20 -c Fox
  • Get all Fox v. Marth games: node getGamesOnDate --characters Fox Marth --excludeDittos
    • NOTE: the --excludeDittos flag is necessary since filtering by characters succeeds when at least one listed character is present (i.e., mirror matchups/dittos)
  • Get games Fox v. Fox, Marth or Sheik: node getGamesOnDate -c Fox --characters Sheik Marth

More help

Run node getGamesOnDate.js -h to get the following:

getGamesOnDate <cmd> [args]

Commands:
  getGamesOnDate all    Get all games in directory; default command
  getGamesOnDate today  Get today's games
  getGamesOnDate on     Get games on a specific date
  getGamesOnDate range  Get games within a given date range

Options:
  --version            Show version number                             [boolean]
  --title, -t          Generate focus title for VOD description
                                              [string] [default: "FoxInTheB0XX"]
  --type, -s           Generate sub focus title for VOD description
                                        [string] [default: "SmashLadder Ranked"]
  --minGameLength, -l  Minimum game length (secs) to include
                                                          [number] [default: 60]
  --minKills, -k       Minimum game kill-count to include  [number] [default: 3]
  --dir, -i            Slippi directory to use (relative to script)
                                                  [string] [default: "./Slippi"]
  --dolphin            Set output filename for dolphin replay file
                                    [string] [default: "./2020-3-3-replay.json"]
  --timestamp          Set output filename for timestamp file
                                   [string] [default: "./2020-3-3-vod-info.txt"]
  --character, -c      Include all games with character (use short name)[string]
  --characters         Include games with characters (use short name; empty =
                       all)                                [array] [default: []]
  --excludeDittos      Exclude games with dittos      [boolean] [default: false]
  --help, -h           Show help                                       [boolean]

Future improvements

  • Automate recording process with OBS websockets
  • Extend character filters capabilities by adding a filter for a specific player(s) (e.g. which characters, what colors, what tags they use, etc.). This would help filter out tournament Slippi file collections.
  • Better set detection. Currently, the script determines a "set" as equivalent to a character change (as one can see from the logs outputted by the script). This is a bit complicated due to the fact that it's currently difficult/impossible to accurately ascertain the "winner" of a game solely from its corresponding Slippi replay file.
  • Wrap CLI into some sort of simple app or contribute back to the Slippi Desktop App

Credits

This script is largely based off and modified from this script.

If you have issues running/starting dolphin, see the information in the first comment of the above mentioned script.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment