@slippi/slippi-js
Version:
Official Project Slippi Javascript SDK
252 lines (249 loc) • 9.59 kB
JavaScript
import fs from 'fs';
import forEach from 'lodash/forEach.js';
import { Writable } from 'stream';
import { Command } from '../../common/types.esm.js';
import { SlpStream, SlpStreamMode, SlpStreamEvent } from '../../common/utils/slpStream.esm.js';
const DEFAULT_NICKNAME = "unknown";
/**
* SlpFile is a class that wraps a Writable stream. It handles the writing of the binary
* header and footer, and also handles the overwriting of the raw data length.
*
* @class SlpFile
* @extends {Writable}
*/
class SlpFile extends Writable {
/**
* Creates an instance of SlpFile.
* @param {string} filePath The file location to write to.
* @param {WritableOptions} [opts] Options for writing.
* @memberof SlpFile
*/
constructor(filePath, slpStream, opts) {
super(opts);
this.rawDataLength = 0;
this.usesExternalStream = false;
this.filePath = filePath;
this.metadata = {
consoleNickname: DEFAULT_NICKNAME,
startTime: new Date(),
lastFrame: -124,
players: {},
};
this.usesExternalStream = Boolean(slpStream);
// Create a new SlpStream if one wasn't already provided
// This SLP stream represents a single game not multiple, so use manual mode
this.slpStream = slpStream ? slpStream : new SlpStream({ mode: SlpStreamMode.MANUAL });
this._setupListeners();
this._initializeNewGame(this.filePath);
}
/**
* Get the current file path being written to.
*
* @returns {string} The location of the current file path
* @memberof SlpFile
*/
path() {
return this.filePath;
}
/**
* Sets the metadata of the Slippi file, such as consoleNickname, lastFrame, and players.
* @param metadata The metadata to be written
*/
setMetadata(metadata) {
this.metadata = Object.assign({}, this.metadata, metadata);
}
_write(chunk, encoding, callback) {
if (encoding !== "buffer") {
throw new Error(`Unsupported stream encoding. Expected 'buffer' got '${encoding}'.`);
}
// Write it to the file
if (this.fileStream) {
this.fileStream.write(chunk);
}
// Parse the data manually if it's an internal stream
if (!this.usesExternalStream) {
this.slpStream.process(chunk);
}
// Keep track of the bytes we've written
this.rawDataLength += chunk.length;
callback();
}
/**
* Here we define what to do on each command. We need to populate the metadata field
* so we keep track of the latest frame, as well as the number of frames each character has
* been used.
*
* @param data The parsed data from a SlpStream
*/
_onCommand(data) {
const { command, payload } = data;
switch (command) {
case Command.GAME_START:
const { players } = payload;
forEach(players, (player) => {
if (player.type === 3) {
return;
}
this.metadata.players[player.playerIndex] = {
characterUsage: {},
names: {
netplay: player.displayName,
code: player.connectCode,
},
};
});
break;
case Command.POST_FRAME_UPDATE:
// Here we need to update some metadata fields
const { frame, playerIndex, isFollower, internalCharacterId } = payload;
if (isFollower) {
// No need to do this for follower
break;
}
// Update frame index
this.metadata.lastFrame = frame;
// Update character usage
const prevPlayer = this.metadata.players[playerIndex];
const characterUsage = prevPlayer.characterUsage;
const curCharFrames = characterUsage[internalCharacterId] || 0;
const player = Object.assign(Object.assign({}, prevPlayer), { characterUsage: Object.assign(Object.assign({}, characterUsage), { [internalCharacterId]: curCharFrames + 1 }) });
this.metadata.players[playerIndex] = player;
break;
}
}
_setupListeners() {
const streamListener = (data) => {
this._onCommand(data);
};
this.slpStream.on(SlpStreamEvent.COMMAND, streamListener);
this.on("finish", () => {
// Update file with bytes written
const fd = fs.openSync(this.filePath, "r+");
fs.writeSync(fd, createUInt32Buffer(this.rawDataLength), 0, 4, 11);
fs.closeSync(fd);
// Unsubscribe from the stream
this.slpStream.removeListener(SlpStreamEvent.COMMAND, streamListener);
// Clean up the internal processor
if (!this.usesExternalStream) {
this.slpStream.restart();
}
});
}
_initializeNewGame(filePath) {
this.fileStream = fs.createWriteStream(filePath, {
encoding: "binary",
});
const header = Buffer.concat([
Buffer.from("{U"),
Buffer.from([3]),
Buffer.from("raw[$U#l"),
Buffer.from([0, 0, 0, 0]),
]);
this.fileStream.write(header);
}
_final(callback) {
let footer = Buffer.concat([Buffer.from("U"), Buffer.from([8]), Buffer.from("metadata{")]);
// Write game start time
const startTimeStr = this.metadata.startTime.toISOString();
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([7]),
Buffer.from("startAtSU"),
Buffer.from([startTimeStr.length]),
Buffer.from(startTimeStr),
]);
// Write last frame index
// TODO: Get last frame
const lastFrame = this.metadata.lastFrame;
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([9]),
Buffer.from("lastFramel"),
createInt32Buffer(lastFrame),
]);
// write the Console Nickname
const consoleNick = this.metadata.consoleNickname || DEFAULT_NICKNAME;
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([11]),
Buffer.from("consoleNickSU"),
Buffer.from([consoleNick.length]),
Buffer.from(consoleNick),
]);
// Start writting player specific data
footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("players{")]);
const players = this.metadata.players;
forEach(players, (player, index) => {
// Start player obj with index being the player index
footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([index.length]), Buffer.from(`${index}{`)]);
// Start characters key for this player
footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([10]), Buffer.from("characters{")]);
// Write character usage
forEach(player.characterUsage, (usage, internalId) => {
// Write this character
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([internalId.length]),
Buffer.from(`${internalId}l`),
createUInt32Buffer(usage),
]);
});
// Close characters
footer = Buffer.concat([footer, Buffer.from("}")]);
// Start names key for this player
footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([5]), Buffer.from("names{")]);
// Write display name
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([7]),
Buffer.from("netplaySU"),
Buffer.from([player.names.netplay.length]),
Buffer.from(`${player.names.netplay}`),
]);
// Write connect code
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([4]),
Buffer.from("codeSU"),
Buffer.from([player.names.code.length]),
Buffer.from(`${player.names.code}`),
]);
// Close names and player
footer = Buffer.concat([footer, Buffer.from("}}")]);
});
// Close players
footer = Buffer.concat([footer, Buffer.from("}")]);
// Write played on
footer = Buffer.concat([
footer,
Buffer.from("U"),
Buffer.from([8]),
Buffer.from("playedOnSU"),
Buffer.from([7]),
Buffer.from("network"),
]);
// Close metadata and file
footer = Buffer.concat([footer, Buffer.from("}}")]);
// End the stream
if (this.fileStream) {
this.fileStream.end(footer, callback);
}
}
}
const createInt32Buffer = (number) => {
const buf = Buffer.alloc(4);
buf.writeInt32BE(number, 0);
return buf;
};
const createUInt32Buffer = (number) => {
const buf = Buffer.alloc(4);
buf.writeUInt32BE(number, 0);
return buf;
};
export { SlpFile };