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

project-slippi / slippi-js / 19563465494

21 Nov 2025 07:36AM UTC coverage: 81.538% (+0.1%) from 81.39%
19563465494

Pull #150

github

web-flow
Merge b1f8f4d84 into 02a2c65e6
Pull Request #150: Refactor SlippiGame to be web-compatible by default

694 of 926 branches covered (74.95%)

Branch coverage included in aggregate %.

156 of 173 new or added lines in 15 files covered. (90.17%)

1956 of 2324 relevant lines covered (84.17%)

120539.15 hits per line

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

79.55
/src/common/utils/slpParser.ts
1
import get from "lodash/get";
12✔
2
import keyBy from "lodash/keyBy";
12✔
3
import set from "lodash/set";
12✔
4
import semver from "semver";
12✔
5

6
import type {
7
  EnabledItemType,
8
  FodPlatformType,
9
  FrameBookendType,
10
  FrameEntryType,
11
  FrameStartType,
12
  FramesType,
13
  GameEndType,
14
  GameStartType,
15
  GeckoListType,
16
  ItemUpdateType,
17
  PostFrameUpdateType,
18
  PreFrameUpdateType,
19
  RollbackFrames,
20
  StadiumTransformationType,
21
  StageEventTypes,
22
  WhispyType,
23
} from "../types";
24
import { ItemSpawnType } from "../types";
12✔
25
import { Command, Frames, GameMode } from "../types";
12✔
26
import { exists } from "./exists";
12✔
27
import { RollbackCounter } from "./rollbackCounter";
12✔
28
import { TypedEventEmitter } from "./typedEventEmitter";
12✔
29

30
// There are 5 bytes of item bitfields that can be enabled
31
const ITEM_SETTINGS_BIT_COUNT = 40;
12✔
32
export const MAX_ROLLBACK_FRAMES = 7;
12✔
33

34
export enum SlpParserEvent {
12✔
35
  SETTINGS = "settings",
12✔
36
  END = "end",
12✔
37
  FRAME = "frame", // Emitted for every frame
12✔
38
  FINALIZED_FRAME = "finalized-frame", // Emitted for only finalized frames
12✔
39
  ROLLBACK_FRAME = "rollback-frame", // Emitted if a frame is being replaced
12✔
40
}
41

42
// If strict mode is on, we will do strict validation checking
43
// which could throw errors on invalid data.
44
// Default to false though since probably only real time applications
45
// would care about valid data.
46
const defaultSlpParserOptions = {
12✔
47
  strict: false,
48
};
49

50
export type SlpParserOptions = typeof defaultSlpParserOptions;
51

52
type SlpParserEventMap = {
53
  [SlpParserEvent.SETTINGS]: GameStartType;
54
  [SlpParserEvent.END]: GameEndType;
55
  [SlpParserEvent.FRAME]: FrameEntryType;
56
  [SlpParserEvent.FINALIZED_FRAME]: FrameEntryType;
57
  [SlpParserEvent.ROLLBACK_FRAME]: FrameEntryType;
58
};
59

60
export class SlpParser extends TypedEventEmitter<SlpParserEventMap> {
12✔
61
  private frames: FramesType = {};
81✔
62
  private rollbackCounter: RollbackCounter = new RollbackCounter();
81✔
63
  private settings: GameStartType | null = null;
81✔
64
  private gameEnd: GameEndType | null = null;
81✔
65
  private latestFrameIndex: number | null = null;
81✔
66
  private settingsComplete = false;
81✔
67
  private lastFinalizedFrame = Frames.FIRST - 1;
81✔
68
  private options: SlpParserOptions;
69
  private geckoList: GeckoListType | null = null;
81✔
70

71
  public constructor(options?: Partial<SlpParserOptions>) {
72
    super();
81✔
73
    this.options = Object.assign({}, defaultSlpParserOptions, options);
81✔
74
  }
75

76
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
  public handleCommand(command: Command, payload: any): void {
78
    switch (command) {
1,566,401✔
79
      case Command.GAME_START:
80
        this._handleGameStart(payload as GameStartType);
77✔
81
        break;
77✔
82
      case Command.FRAME_START:
83
        this._handleFrameStart(payload as FrameStartType);
218,400✔
84
        break;
218,400✔
85
      case Command.POST_FRAME_UPDATE:
86
        // We need to handle the post frame update first since that
87
        // will finalize the settings object, before we fire the frame update
88
        this._handlePostFrameUpdate(payload as PostFrameUpdateType);
548,099✔
89
        this._handleFrameUpdate(command, payload as PostFrameUpdateType);
548,099✔
90
        break;
548,099✔
91
      case Command.PRE_FRAME_UPDATE:
92
        this._handleFrameUpdate(command, payload as PreFrameUpdateType);
548,105✔
93
        break;
548,105✔
94
      case Command.ITEM_UPDATE:
95
        this._handleItemUpdate(payload as ItemUpdateType);
32,565✔
96
        break;
32,565✔
97
      case Command.FRAME_BOOKEND:
98
        this._handleFrameBookend(payload as FrameBookendType);
218,400✔
99
        break;
218,400✔
100
      case Command.GAME_END:
101
        this._handleGameEnd(payload as GameEndType);
54✔
102
        break;
54✔
103
      case Command.GECKO_LIST:
104
        this._handleGeckoList(payload as GeckoListType);
46✔
105
        break;
46✔
106
      case Command.FOD_PLATFORM:
107
        this._handleStageEvent(payload as FodPlatformType);
589✔
108
        break;
589✔
109
      case Command.WHISPY:
110
        this._handleStageEvent(payload as WhispyType);
15✔
111
        break;
15✔
112
      case Command.STADIUM_TRANSFORMATION:
113
        this._handleStageEvent(payload as StadiumTransformationType);
48✔
114
        break;
48✔
115
    }
116
  }
117

118
  /**
119
   * Resets the parser state to their default values.
120
   */
121
  public reset(): void {
122
    this.frames = {};
×
123
    this.settings = null;
×
124
    this.gameEnd = null;
×
125
    this.latestFrameIndex = null;
×
126
    this.settingsComplete = false;
×
127
    this.lastFinalizedFrame = Frames.FIRST - 1;
×
128
  }
129

130
  public getLatestFrameNumber(): number {
131
    return this.latestFrameIndex ?? Frames.FIRST - 1;
33✔
132
  }
133

134
  public getPlayableFrameCount(): number {
135
    if (this.latestFrameIndex === null) {
33✔
136
      return 0;
1✔
137
    }
138
    return this.latestFrameIndex < Frames.FIRST_PLAYABLE ? 0 : this.latestFrameIndex - Frames.FIRST_PLAYABLE;
32✔
139
  }
140

141
  public getLatestFrame(): FrameEntryType | null {
142
    // return this.playerFrames[this.latestFrameIndex];
143

144
    // TODO: Modify this to check if we actually have all the latest frame data and return that
145
    // TODO: If we do. For now I'm just going to take a shortcut
146
    const allFrames = this.getFrames();
12✔
147
    const frameIndex = this.latestFrameIndex !== null ? this.latestFrameIndex : Frames.FIRST;
12✔
148
    const indexToUse = this.gameEnd ? frameIndex : frameIndex - 1;
12✔
149
    return get(allFrames, indexToUse) || null;
12✔
150
  }
151

152
  public getSettings(): GameStartType | null {
153
    return this.settingsComplete ? this.settings : null;
1,096,348✔
154
  }
155

156
  public getItems(): EnabledItemType[] | null {
157
    if (this.settings?.itemSpawnBehavior === ItemSpawnType.OFF) {
×
158
      return null;
×
159
    }
160

161
    const itemBitfield = this.settings?.enabledItems;
×
162
    if (!exists(itemBitfield)) {
×
163
      return null;
×
164
    }
165

166
    const enabledItems: EnabledItemType[] = [];
×
167

168
    // Ideally we would be able to do this with bitshifting instead, but javascript
169
    // truncates numbers after 32 bits when doing bitwise operations
170
    for (let i = 0; i < ITEM_SETTINGS_BIT_COUNT; i++) {
×
171
      if (Math.floor(itemBitfield / 2 ** i) & 1) {
×
172
        enabledItems.push(2 ** i);
×
173
      }
174
    }
175

176
    return enabledItems;
×
177
  }
178

179
  public getGameEnd(): GameEndType | null {
180
    return this.gameEnd;
151✔
181
  }
182

183
  public getFrames(): FramesType {
184
    return this.frames;
24✔
185
  }
186

187
  public getRollbackFrames(): RollbackFrames {
188
    return {
1✔
189
      frames: this.rollbackCounter.getFrames(),
190
      count: this.rollbackCounter.getCount(),
191
      lengths: this.rollbackCounter.getLengths(),
192
    };
193
  }
194

195
  public getFrame(num: number): FrameEntryType | null {
196
    return this.frames[num] || null;
227,054!
197
  }
198

199
  public getGeckoList(): GeckoListType | null {
200
    return this.geckoList;
3✔
201
  }
202

203
  private _handleGeckoList(payload: GeckoListType): void {
204
    this.geckoList = payload;
46✔
205
  }
206

207
  private _handleGameEnd(payload: GameEndType): void {
208
    // Finalize remaining frames if necessary
209
    if (this.latestFrameIndex !== null && this.latestFrameIndex !== this.lastFinalizedFrame) {
54✔
210
      this._finalizeFrames(this.latestFrameIndex);
41✔
211
    }
212

213
    this.gameEnd = payload;
54✔
214
    this.emit(SlpParserEvent.END, this.gameEnd);
54✔
215
  }
216

217
  private _handleGameStart(payload: GameStartType): void {
218
    this.settings = payload;
77✔
219
    const players = payload.players;
77✔
220
    this.settings.players = players.filter((player) => player.type !== 3);
308✔
221

222
    // Check to see if the file was created after the sheik fix so we know
223
    // we don't have to process the first frame of the game for the full settings
224
    if (payload.slpVersion && semver.gte(payload.slpVersion, "1.6.0")) {
77✔
225
      this._completeSettings();
67✔
226
    }
227
  }
228

229
  private _handleFrameStart(payload: FrameStartType): void {
230
    const currentFrameNumber = payload.frame!;
218,400✔
231

232
    set(this.frames, [currentFrameNumber, "start"], payload);
218,400✔
233
  }
234

235
  private _handlePostFrameUpdate(payload: PostFrameUpdateType): void {
236
    if (this.settingsComplete) {
548,099✔
237
      return;
548,062✔
238
    }
239

240
    // Finish calculating settings
241
    if (payload.frame! <= Frames.FIRST) {
37✔
242
      const playerIndex = payload.playerIndex!;
27✔
243
      const playersByIndex = keyBy(this.settings!.players, "playerIndex");
27✔
244

245
      switch (payload.internalCharacterId) {
27!
246
        case 0x7:
247
          playersByIndex[playerIndex]!.characterId = 0x13; // Sheik
5✔
248
          break;
5✔
249
        case 0x13:
250
          playersByIndex[playerIndex]!.characterId = 0x12; // Zelda
×
251
          break;
×
252
      }
253
    }
254
    if (payload.frame! > Frames.FIRST) {
37✔
255
      this._completeSettings();
10✔
256
    }
257
  }
258

259
  private _handleFrameUpdate(command: Command, payload: PreFrameUpdateType | PostFrameUpdateType): void {
260
    const location = command === Command.PRE_FRAME_UPDATE ? "pre" : "post";
1,096,204✔
261
    const field = payload.isFollower ? "followers" : "players";
1,096,204✔
262
    const currentFrameNumber = payload.frame!;
1,096,204✔
263
    this.latestFrameIndex = currentFrameNumber;
1,096,204✔
264
    if (location === "pre" && !payload.isFollower) {
1,096,204✔
265
      const currentFrame = this.frames[currentFrameNumber];
537,889✔
266
      const wasRolledback = this.rollbackCounter.checkIfRollbackFrame(currentFrame, payload.playerIndex!);
537,889✔
267
      if (wasRolledback && currentFrame) {
537,889✔
268
        // frame is about to be overwritten
269
        this.emit(SlpParserEvent.ROLLBACK_FRAME, currentFrame);
31,338✔
270
      }
271
    }
272
    set(this.frames, [currentFrameNumber, field, payload.playerIndex!, location], payload);
1,096,204✔
273
    set(this.frames, [currentFrameNumber, "frame"], currentFrameNumber);
1,096,204✔
274

275
    // If file is from before frame bookending, add frame to stats computer here. Does a little
276
    // more processing than necessary, but it works
277
    const settings = this.getSettings();
1,096,204✔
278
    if (settings && (!settings.slpVersion || semver.lte(settings.slpVersion, "2.2.0"))) {
1,096,204✔
279
      const frame = this.frames[currentFrameNumber];
211,545✔
280
      if (frame) {
211,545✔
281
        this.emit(SlpParserEvent.FRAME, frame);
211,545✔
282
      }
283
      // Finalize the previous frame since no bookending exists
284
      this._finalizeFrames(currentFrameNumber - 1);
211,545✔
285
    } else {
286
      set(this.frames, [currentFrameNumber, "isTransferComplete"], false);
884,659✔
287
    }
288
  }
289

290
  private _handleItemUpdate(payload: ItemUpdateType): void {
291
    const currentFrameNumber = payload.frame!;
32,565✔
292
    const items = this.frames[currentFrameNumber]?.items ?? [];
32,565!
293
    items.push(payload);
32,565✔
294

295
    // Set items with newest
296
    set(this.frames, [currentFrameNumber, "items"], items);
32,565✔
297
  }
298

299
  private _handleFrameBookend(payload: FrameBookendType): void {
300
    const latestFinalizedFrame = payload.latestFinalizedFrame!;
218,400✔
301
    const currentFrameNumber = payload.frame!;
218,400✔
302
    set(this.frames, [currentFrameNumber, "isTransferComplete"], true);
218,400✔
303
    // Fire off a normal frame event
304
    const frame = this.frames[currentFrameNumber];
218,400✔
305
    if (frame) {
218,400✔
306
      this.emit(SlpParserEvent.FRAME, frame);
218,400✔
307
    }
308

309
    // Finalize frames if necessary
310
    const validLatestFrame = this.settings!.gameMode === GameMode.ONLINE;
218,400✔
311
    if (validLatestFrame && latestFinalizedFrame >= Frames.FIRST) {
218,400✔
312
      // Ensure valid latestFinalizedFrame
313
      if (this.options.strict && latestFinalizedFrame < currentFrameNumber - MAX_ROLLBACK_FRAMES) {
144,286!
314
        throw new Error(`latestFinalizedFrame should be within ${MAX_ROLLBACK_FRAMES} frames of ${currentFrameNumber}`);
×
315
      }
316
      this._finalizeFrames(latestFinalizedFrame);
144,286✔
317
    } else {
318
      // Since we don't have a valid finalized frame, just finalize the frame based on MAX_ROLLBACK_FRAMES
319
      this._finalizeFrames(currentFrameNumber - MAX_ROLLBACK_FRAMES);
74,114✔
320
    }
321
  }
322

323
  private _handleStageEvent(payload: StageEventTypes): void {
324
    const currentFrameNumber = payload.frame!;
652✔
325
    const stageEvents = this.frames[currentFrameNumber]?.stageEvents ?? [];
652!
326
    stageEvents.push(payload);
652✔
327

328
    // Set stageEvents with newest
329
    set(this.frames, [currentFrameNumber, "stageEvents"], stageEvents);
652✔
330
  }
331

332
  /**
333
   * Fires off the FINALIZED_FRAME event for frames up until a certain number
334
   * @param num The frame to finalize until
335
   */
336
  private _finalizeFrames(num: number): void {
337
    while (this.lastFinalizedFrame < num) {
429,986✔
338
      const frameToFinalize = this.lastFinalizedFrame + 1;
227,054✔
339
      const frame = this.getFrame(frameToFinalize);
227,054✔
340

341
      // If the frame doesn't exist, we can't finalize it yet
342
      // This can happen when trying to finalize frames that haven't been received
343
      if (!frame) {
227,054!
NEW
344
        break;
×
345
      }
346

347
      // Check that we have all the pre and post frame data for all players if we're in strict mode
348
      if (this.options.strict) {
227,054!
349
        for (const player of this.settings!.players) {
×
350
          const playerFrameInfo = frame.players[player.playerIndex];
×
351
          // Allow player frame info to be empty in non 1v1 games since
352
          // players which have been defeated will have no frame info.
353
          if (this.settings!.players.length > 2 && !playerFrameInfo) {
×
354
            continue;
×
355
          }
356

357
          const { pre, post } = playerFrameInfo!;
×
358
          if (!pre || !post) {
×
359
            const preOrPost = pre ? "pre" : "post";
×
360
            throw new Error(
×
361
              `Could not finalize frame ${frameToFinalize} of ${num}: missing ${preOrPost}-frame update for player ${player.playerIndex}`,
362
            );
363
          }
364
        }
365
      }
366

367
      // Our frame is complete so finalize the frame
368
      this.emit(SlpParserEvent.FINALIZED_FRAME, frame);
227,054✔
369
      this.lastFinalizedFrame = frameToFinalize;
227,054✔
370
    }
371
  }
372

373
  private _completeSettings(): void {
374
    if (!this.settingsComplete) {
77✔
375
      this.settingsComplete = true;
73✔
376
      if (this.settings) {
73✔
377
        this.emit(SlpParserEvent.SETTINGS, this.settings);
73✔
378
      }
379
    }
380
  }
381
}
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

© 2026 Coveralls, Inc