• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

project-slippi / slippi-js / 19753474081

28 Nov 2025 03:45AM UTC coverage: 81.336% (+0.08%) from 81.254%
19753474081

Pull #150

github

web-flow
Merge 7b34002d4 into 30fdf9839
Pull Request #150: Refactor SlippiGame to be web-compatible by default

694 of 928 branches covered (74.78%)

Branch coverage included in aggregate %.

158 of 178 new or added lines in 16 files covered. (88.76%)

1960 of 2335 relevant lines covered (83.94%)

119971.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.48
/src/node/utils/slpFile.ts
1
import type { WriteStream } from "fs";
2
import fs from "fs";
12✔
3
import forEach from "lodash/forEach";
12✔
4
import type { WritableOptions } from "stream";
5
import { Writable } from "stream";
12✔
6

7
import type { GameStartType, PostFrameUpdateType } from "../../common/types";
8
import { Command } from "../../common/types";
12✔
9
import type { SlpCommandEventPayload } from "../../common/utils/slpStream";
10
import { SlpStream, SlpStreamEvent, SlpStreamMode } from "../../common/utils/slpStream";
12✔
11

12
const DEFAULT_NICKNAME = "unknown";
12✔
13

14
export type SlpFileMetadata = {
15
  startTime: Date;
16
  lastFrame: number;
17
  players: {
18
    [playerIndex: number]: {
19
      characterUsage: {
20
        [internalCharacterId: number]: number;
21
      };
22
      names: {
23
        netplay: string;
24
        code: string;
25
      };
26
    };
27
  };
28
  consoleNickname?: string;
29
};
30

31
/**
32
 * SlpFile is a class that wraps a Writable stream. It handles the writing of the binary
33
 * header and footer, and also handles the overwriting of the raw data length.
34
 *
35
 * @class SlpFile
36
 * @extends {Writable}
37
 */
38
export class SlpFile extends Writable {
12✔
39
  private filePath: string;
40
  private metadata: SlpFileMetadata;
41
  private fileStream: WriteStream | null = null;
3✔
42
  private rawDataLength = 0;
3✔
43
  private slpStream: SlpStream;
44
  private usesExternalStream = false;
3✔
45

46
  /**
47
   * Creates an instance of SlpFile.
48
   * @param {string} filePath The file location to write to.
49
   * @param {WritableOptions} [opts] Options for writing.
50
   * @memberof SlpFile
51
   */
52
  public constructor(filePath: string, slpStream?: SlpStream, opts?: WritableOptions) {
53
    super(opts);
3✔
54
    this.filePath = filePath;
3✔
55
    this.metadata = {
3✔
56
      consoleNickname: DEFAULT_NICKNAME,
57
      startTime: new Date(),
58
      lastFrame: -124,
59
      players: {},
60
    };
61
    this.usesExternalStream = Boolean(slpStream);
3✔
62

63
    // Create a new SlpStream if one wasn't already provided
64
    // This SLP stream represents a single game not multiple, so use manual mode
65
    this.slpStream = slpStream ? slpStream : new SlpStream({ mode: SlpStreamMode.MANUAL });
3!
66

67
    this._setupListeners();
3✔
68
    this._initializeNewGame(this.filePath);
3✔
69
  }
70

71
  /**
72
   * Get the current file path being written to.
73
   *
74
   * @returns {string} The location of the current file path
75
   * @memberof SlpFile
76
   */
77
  public path(): string {
78
    return this.filePath;
6✔
79
  }
80

81
  /**
82
   * Sets the metadata of the Slippi file, such as consoleNickname, lastFrame, and players.
83
   * @param metadata The metadata to be written
84
   */
85
  public setMetadata(metadata: Partial<SlpFileMetadata>): void {
86
    this.metadata = Object.assign({}, this.metadata, metadata);
3✔
87
  }
88

89
  public _write(chunk: Uint8Array, encoding: string, callback: (error?: Error | null) => void): void {
90
    if (encoding !== "buffer") {
336,372!
91
      throw new Error(`Unsupported stream encoding. Expected 'buffer' got '${encoding}'.`);
×
92
    }
93
    // Write it to the file
94
    if (this.fileStream) {
336,372✔
95
      this.fileStream.write(chunk);
336,372✔
96
    }
97

98
    // Parse the data manually if it's an internal stream
99
    if (!this.usesExternalStream) {
336,372!
NEW
100
      this.slpStream.process(chunk);
×
101
    }
102

103
    // Keep track of the bytes we've written
104
    this.rawDataLength += chunk.length;
336,372✔
105
    callback();
336,372✔
106
  }
107

108
  /**
109
   * Here we define what to do on each command. We need to populate the metadata field
110
   * so we keep track of the latest frame, as well as the number of frames each character has
111
   * been used.
112
   *
113
   * @param data The parsed data from a SlpStream
114
   */
115
  private _onCommand(data: SlpCommandEventPayload): void {
116
    const { command, payload } = data;
336,172✔
117
    switch (command) {
336,172✔
118
      case Command.GAME_START:
119
        const { players } = payload as GameStartType;
3✔
120
        forEach(players, (player) => {
3✔
121
          if (player.type === 3) {
12✔
122
            return;
4✔
123
          }
124

125
          this.metadata.players[player.playerIndex] = {
8✔
126
            characterUsage: {},
127
            names: {
128
              netplay: player.displayName,
129
              code: player.connectCode,
130
            },
131
          };
132
        });
133
        break;
3✔
134
      case Command.POST_FRAME_UPDATE:
135
        // Here we need to update some metadata fields
136
        const { frame, playerIndex, isFollower, internalCharacterId } = payload as PostFrameUpdateType;
118,048✔
137
        if (isFollower) {
118,048!
138
          // No need to do this for follower
139
          break;
×
140
        }
141

142
        // Update frame index
143
        this.metadata.lastFrame = frame!;
118,048✔
144

145
        // Update character usage
146
        const prevPlayer = this.metadata.players[playerIndex!];
118,048✔
147
        const characterUsage = prevPlayer!.characterUsage;
118,048✔
148
        const curCharFrames = characterUsage[internalCharacterId!] || 0;
118,048✔
149
        const player = {
118,048✔
150
          ...prevPlayer,
151
          characterUsage: {
152
            ...characterUsage,
153
            [internalCharacterId!]: curCharFrames + 1,
154
          },
155
        };
156
        (this.metadata.players as any)[playerIndex!] = player;
118,048✔
157
        break;
118,048✔
158
    }
159
  }
160

161
  private _setupListeners(): void {
162
    const streamListener = (data: SlpCommandEventPayload): void => {
3✔
163
      this._onCommand(data);
336,172✔
164
    };
165
    this.slpStream.on(SlpStreamEvent.COMMAND, streamListener);
3✔
166

167
    this.on("finish", () => {
3✔
168
      // Update file with bytes written
169
      const fd = fs.openSync(this.filePath, "r+");
3✔
170
      fs.writeSync(fd, createUInt32Buffer(this.rawDataLength), 0, 4, 11);
3✔
171
      fs.closeSync(fd);
3✔
172

173
      // Unsubscribe from the stream
174
      this.slpStream.removeListener(SlpStreamEvent.COMMAND, streamListener);
3✔
175
      // Clean up the internal processor
176
      if (!this.usesExternalStream) {
3!
NEW
177
        this.slpStream.restart();
×
178
      }
179
    });
180
  }
181

182
  private _initializeNewGame(filePath: string): void {
183
    this.fileStream = fs.createWriteStream(filePath, {
3✔
184
      encoding: "binary",
185
    });
186

187
    const header = Buffer.concat([
3✔
188
      Buffer.from("{U"),
189
      Buffer.from([3]),
190
      Buffer.from("raw[$U#l"),
191
      Buffer.from([0, 0, 0, 0]),
192
    ]);
193
    this.fileStream.write(header);
3✔
194
  }
195

196
  public _final(callback: (error?: Error | null) => void): void {
197
    let footer = Buffer.concat([Buffer.from("U"), Buffer.from([8]), Buffer.from("metadata{")]);
3✔
198

199
    // Write game start time
200
    const startTimeStr = this.metadata.startTime.toISOString();
3✔
201
    footer = Buffer.concat([
3✔
202
      footer,
203
      Buffer.from("U"),
204
      Buffer.from([7]),
205
      Buffer.from("startAtSU"),
206
      Buffer.from([startTimeStr.length]),
207
      Buffer.from(startTimeStr),
208
    ]);
209

210
    // Write last frame index
211
    // TODO: Get last frame
212
    const lastFrame = this.metadata.lastFrame;
3✔
213
    footer = Buffer.concat([
3✔
214
      footer,
215
      Buffer.from("U"),
216
      Buffer.from([9]),
217
      Buffer.from("lastFramel"),
218
      createInt32Buffer(lastFrame),
219
    ]);
220

221
    // write the Console Nickname
222
    const consoleNick = this.metadata.consoleNickname || DEFAULT_NICKNAME;
3!
223
    footer = Buffer.concat([
3✔
224
      footer,
225
      Buffer.from("U"),
226
      Buffer.from([11]),
227
      Buffer.from("consoleNickSU"),
228
      Buffer.from([consoleNick.length]),
229
      Buffer.from(consoleNick),
230
    ]);
231

232
    // Start writting player specific data
233
    footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([7]), Buffer.from("players{")]);
3✔
234
    const players = this.metadata.players;
3✔
235
    forEach(players, (player, index) => {
3✔
236
      // Start player obj with index being the player index
237
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([index.length]), Buffer.from(`${index}{`)]);
8✔
238

239
      // Start characters key for this player
240
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([10]), Buffer.from("characters{")]);
8✔
241

242
      // Write character usage
243
      forEach(player.characterUsage, (usage, internalId) => {
8✔
244
        // Write this character
245
        footer = Buffer.concat([
8✔
246
          footer,
247
          Buffer.from("U"),
248
          Buffer.from([internalId.length]),
249
          Buffer.from(`${internalId}l`),
250
          createUInt32Buffer(usage),
251
        ]);
252
      });
253

254
      // Close characters
255
      footer = Buffer.concat([footer, Buffer.from("}")]);
8✔
256

257
      // Start names key for this player
258
      footer = Buffer.concat([footer, Buffer.from("U"), Buffer.from([5]), Buffer.from("names{")]);
8✔
259

260
      // Write display name
261
      footer = Buffer.concat([
8✔
262
        footer,
263
        Buffer.from("U"),
264
        Buffer.from([7]),
265
        Buffer.from("netplaySU"),
266
        Buffer.from([player.names.netplay.length]),
267
        Buffer.from(`${player.names.netplay}`),
268
      ]);
269

270
      // Write connect code
271
      footer = Buffer.concat([
8✔
272
        footer,
273
        Buffer.from("U"),
274
        Buffer.from([4]),
275
        Buffer.from("codeSU"),
276
        Buffer.from([player.names.code.length]),
277
        Buffer.from(`${player.names.code}`),
278
      ]);
279

280
      // Close names and player
281
      footer = Buffer.concat([footer, Buffer.from("}}")]);
8✔
282
    });
283

284
    // Close players
285
    footer = Buffer.concat([footer, Buffer.from("}")]);
3✔
286

287
    // Write played on
288
    footer = Buffer.concat([
3✔
289
      footer,
290
      Buffer.from("U"),
291
      Buffer.from([8]),
292
      Buffer.from("playedOnSU"),
293
      Buffer.from([7]),
294
      Buffer.from("network"),
295
    ]);
296

297
    // Close metadata and file
298
    footer = Buffer.concat([footer, Buffer.from("}}")]);
3✔
299

300
    // End the stream
301
    if (this.fileStream) {
3✔
302
      this.fileStream.end(footer, callback);
3✔
303
    }
304
  }
305
}
306

307
const createInt32Buffer = (number: number): Buffer => {
12✔
308
  const buf = Buffer.alloc(4);
3✔
309
  buf.writeInt32BE(number, 0);
3✔
310
  return buf;
3✔
311
};
312

313
const createUInt32Buffer = (number: number): Buffer => {
12✔
314
  const buf = Buffer.alloc(4);
11✔
315
  buf.writeUInt32BE(number, 0);
11✔
316
  return buf;
11✔
317
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc