Skip to content

Commit 86abf39

Browse files
authored
fix(Replay): improve finish detection (#425)
* chore(build): bump jest and ts-jest, and run yarn upgrade * feat(Replay): add timeInMilliseconds getter for Event * fix(Replay): improve finish detection Fixes many edge cases (such as finishing with flower + apple, or escing/dying at flower), and improves multi rec handling. Added new tests with recs featuring these edge cases. * fix(tests): fix ReplayFinishStateReason in old tests Due to handling flower+apple finishes properly now, EventType.Apple as last event doesn't count as NoTouch anymore for these cases.
1 parent 317b20c commit 86abf39

15 files changed

+2288
-2165
lines changed
14.9 KB
Binary file not shown.
44.8 KB
Binary file not shown.

__tests__/assets/replays/40MarAbu.rec

72.1 KB
Binary file not shown.

__tests__/assets/replays/53x813.rec

14.2 KB
Binary file not shown.
7.22 KB
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

__tests__/rec.int.test.ts

+112-2
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ describe('Replay', () => {
135135
});
136136
});
137137

138+
test('getTime, finished, single, flower and apple', async () => {
139+
const filePath = '__tests__/assets/replays/14be1756wow.rec';
140+
const file = await readFile(filePath);
141+
const rec = Replay.from(file);
142+
expect(rec.getTime()).toEqual({
143+
finished: true,
144+
reason: ReplayFinishStateReason.Touch,
145+
time: 17566,
146+
});
147+
});
148+
149+
test('getTime, finished, single, large framediff', async () => {
150+
const filePath = '__tests__/assets/replays/22edahl4219.rec';
151+
const file = await readFile(filePath);
152+
const rec = Replay.from(file);
153+
expect(rec.getTime()).toEqual({
154+
finished: true,
155+
reason: ReplayFinishStateReason.Touch,
156+
time: 42199,
157+
});
158+
});
159+
138160
test('getTime, finished, multi', async () => {
139161
const filePath = '__tests__/assets/replays/rec_valid_2.rec';
140162
const file = await readFile(filePath);
@@ -146,6 +168,50 @@ describe('Replay', () => {
146168
});
147169
});
148170

171+
test('getTime, finished, multi, wait 1', async () => {
172+
const filePath = '__tests__/assets/replays/multi_finished_p1_waits_at_flower.rec';
173+
const file = await readFile(filePath);
174+
const rec = Replay.from(file);
175+
expect(rec.getTime()).toEqual({
176+
finished: true,
177+
reason: ReplayFinishStateReason.Touch,
178+
time: 8294,
179+
});
180+
});
181+
182+
test('getTime, finished, multi, wait 2', async () => {
183+
const filePath = '__tests__/assets/replays/multi_finished_p2_waits_at_flower.rec';
184+
const file = await readFile(filePath);
185+
const rec = Replay.from(file);
186+
expect(rec.getTime()).toEqual({
187+
finished: true,
188+
reason: ReplayFinishStateReason.Touch,
189+
time: 7815,
190+
});
191+
});
192+
193+
test('getTime, finished, multi, p1 flower and p2 groundtouch', async () => {
194+
const filePath = '__tests__/assets/replays/40MarAbu.rec';
195+
const file = await readFile(filePath);
196+
const rec = Replay.from(file);
197+
expect(rec.getTime()).toEqual({
198+
finished: true,
199+
reason: ReplayFinishStateReason.Touch,
200+
time: 37090,
201+
});
202+
});
203+
204+
test('getTime, finished, multi, large framediff', async () => {
205+
const filePath = '__tests__/assets/replays/53x813.rec';
206+
const file = await readFile(filePath);
207+
const rec = Replay.from(file);
208+
expect(rec.getTime()).toEqual({
209+
finished: true,
210+
reason: ReplayFinishStateReason.Touch,
211+
time: 8139,
212+
});
213+
});
214+
149215
test('getTime, unfinished, no event', async () => {
150216
const filePath = '__tests__/assets/replays/unfinished.rec';
151217
const file = await readFile(filePath);
@@ -168,13 +234,24 @@ describe('Replay', () => {
168234
});
169235
});
170236

237+
test('getTime, unfinished, single, esc', async () => {
238+
const filePath = '__tests__/assets/replays/esc_at_flower.rec';
239+
const file = await readFile(filePath);
240+
const rec = Replay.from(file);
241+
expect(rec.getTime()).toEqual({
242+
finished: false,
243+
reason: ReplayFinishStateReason.NoTouch,
244+
time: 4867,
245+
});
246+
});
247+
171248
test('getTime, unfinished, multi, event', async () => {
172249
const filePath = '__tests__/assets/replays/multi_event_unfinished.rec';
173250
const file = await readFile(filePath);
174251
const rec = Replay.from(file);
175252
expect(rec.getTime()).toEqual({
176253
finished: false,
177-
reason: ReplayFinishStateReason.NoTouch,
254+
reason: ReplayFinishStateReason.FrameDifference,
178255
time: 1600,
179256
});
180257
});
@@ -190,13 +267,46 @@ describe('Replay', () => {
190267
});
191268
});
192269

270+
test('getTime, unfinished, multi, no event', async () => {
271+
const filePath = '__tests__/assets/replays/multi_unfinished_no_events.rec';
272+
const file = await readFile(filePath);
273+
const rec = Replay.from(file);
274+
expect(rec.getTime()).toEqual({
275+
finished: false,
276+
reason: ReplayFinishStateReason.NoTouch,
277+
time: 133,
278+
});
279+
});
280+
281+
test('getTime, unfinished, multi, esc 1', async () => {
282+
const filePath = '__tests__/assets/replays/multi_unfinished_p1_escs_at_flower.rec';
283+
const file = await readFile(filePath);
284+
const rec = Replay.from(file);
285+
expect(rec.getTime()).toEqual({
286+
finished: false,
287+
reason: ReplayFinishStateReason.NoTouch,
288+
time: 4300,
289+
});
290+
});
291+
292+
test('getTime, unfinished, multi, esc 2', async () => {
293+
const filePath = '__tests__/assets/replays/multi_unfinished_p2_escs_at_flower.rec';
294+
const file = await readFile(filePath);
295+
const rec = Replay.from(file);
296+
expect(rec.getTime()).toEqual({
297+
finished: false,
298+
reason: ReplayFinishStateReason.NoTouch,
299+
time: 8067,
300+
});
301+
});
302+
193303
test('getTime, unfinished, single, event, framediff', async () => {
194304
const filePath = '__tests__/assets/replays/event_unfinished.rec';
195305
const file = await readFile(filePath);
196306
const rec = Replay.from(file);
197307
expect(rec.getTime()).toEqual({
198308
finished: false,
199-
reason: ReplayFinishStateReason.NoTouch,
309+
reason: ReplayFinishStateReason.FrameDifference,
200310
time: 8567,
201311
});
202312
});

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
"eslint-config-prettier": "^8.3.0",
4242
"eslint-plugin-prettier": "^4.0.0",
4343
"fs-extra": "^9.0.1",
44-
"jest": "^24.7.1",
44+
"jest": "^25.0.0",
4545
"prettier": "^2.0.5",
46-
"ts-jest": "^24.0.2",
46+
"ts-jest": "^25.0.0",
4747
"ts-loader": "^9.2.6",
4848
"typedoc": "^0.22.4",
4949
"typescript": "^4.0.2",

src/rec/Event.ts

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export default class Event {
1010
this.touchInfo = 0;
1111
this.groundInfo = 0;
1212
}
13+
14+
get timeInMilliseconds(): number {
15+
return this.time * (0.001 / (0.182 * 0.0024)) * 1000;
16+
}
1317
}
1418

1519
export enum EventType {

src/rec/Replay.ts

+105-32
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,20 @@ export default class Replay {
111111

112112
let offset = 0;
113113
for (let i = 0; i < numEvents; i++) {
114-
const time = buffer.readDoubleLE(offset);
114+
const event = new Event();
115+
event.time = buffer.readDoubleLE(offset);
115116
offset += 8;
116-
const touchInfo = buffer.readInt16LE(offset);
117+
event.touchInfo = buffer.readInt16LE(offset);
117118
offset += 2;
118-
const type = buffer.readUInt8(offset);
119+
event.type = buffer.readUInt8(offset);
119120
offset += 2; // 1 + 1 padding
120-
const groundInfo = buffer.readFloatLE(offset);
121+
event.groundInfo = buffer.readFloatLE(offset);
121122
offset += 4;
122-
if (type < 0 || type > 7) {
123-
throw new Error(`Invalid event type value=${type} at event offset=${offset}`);
123+
if (event.type < 0 || event.type > 7) {
124+
throw new Error(`Invalid event type value=${event.type} at event offset=${offset}`);
124125
}
125126

126-
events.push({ time, touchInfo, type, groundInfo });
127+
events.push(event);
127128
}
128129

129130
return events;
@@ -230,49 +231,121 @@ export default class Replay {
230231
* Get time of replay in milliseconds.
231232
*/
232233
public getTime(): FinishState {
233-
// First check if last event was a touch event in ride(s) event data.
234-
const lastEvent = this.rides.reduce((prev: undefined | Event, ride) => {
235-
const prevTime = prev ? prev.time : 0;
236-
const lastRideEvent = ride.events.length > 0 ? ride.events[ride.events.length - 1] : undefined;
237-
const lastRideEventTime = lastRideEvent ? lastRideEvent.time : 0;
238-
if (lastRideEventTime > prevTime) {
239-
return lastRideEvent;
240-
}
241-
return prev;
234+
// First get the last event of (each) ride.
235+
const lastEvents = this.rides.map((ride) =>
236+
ride.events.length > 0 ? ride.events[ride.events.length - 1] : undefined,
237+
);
238+
239+
// Time of each ride's last event, in milliseconds.
240+
const lastEventTimes = lastEvents.map((event: undefined | Event) => (event ? event.timeInMilliseconds : undefined));
241+
242+
// Time of the final event(s) (= the event(s) with the highest time), in milliseconds.
243+
const finalEventTime = lastEventTimes.reduce((prev: undefined | number, time: undefined | number) => {
244+
if (!prev) {
245+
return time;
246+
} else if (!time) {
247+
return prev;
248+
} else return time > prev ? time : prev;
242249
}, undefined);
243250

244-
// Highest frame time.
245-
const maxFrames = this.rides.reduce((prev, ride) => {
246-
return ride.frames.length > prev ? ride.frames.length : prev;
251+
// Get all Touch/Apple events with finalEventTime. Other events with finalEventTime are filtered out.
252+
// (For example player 2 might have a GroundTouch event with the same time as player 1's flower Touch.)
253+
const finalEvents = <Event[]>[];
254+
for (const ride of this.rides) {
255+
const touchOrAppleEvents = ride.events.filter(
256+
(event) =>
257+
event.timeInMilliseconds === finalEventTime &&
258+
(event.type === EventType.Touch || event.type === EventType.Apple),
259+
);
260+
finalEvents.push(...touchOrAppleEvents);
261+
}
262+
263+
// Time of each ride's last frame, in milliseconds.
264+
const FrameDuration = 1000 / 30.0;
265+
const lastFrameTimes = this.rides.map((ride) => ride.frames.length * FrameDuration);
266+
267+
// Highest frame time, in milliseconds.
268+
const finalFrameTime = lastFrameTimes.reduce((prev, time) => {
269+
return time > prev ? time : prev;
247270
}, 0);
248-
const maxFrameTime = maxFrames * 33.333;
249271

250-
// If no touch event, return approximate frame time.
251-
if ((lastEvent && lastEvent.type !== EventType.Touch) || !lastEvent) {
272+
// Not finished if the final events don't include a Touch or Apple event, so return approximate frame time.
273+
if (finalEvents.length === 0) {
252274
return {
253275
finished: false,
254276
reason: ReplayFinishStateReason.NoTouch,
255-
time: Math.round(maxFrameTime),
277+
time: Math.round(finalFrameTime),
256278
};
257279
}
258280

259-
// Set to highest event time.
260-
const maxEventTime = lastEvent.time * (0.001 / (0.182 * 0.0024)) * 1000;
281+
// Info about whether each ride's last event is within ~1 frame of time from the end of that ride.
282+
// In finished rides, flower Touch is sometimes slightly more than FrameDuration away from the end,
283+
// so a small extra tolerance is allowed. (In singleplayer replays, the extra time can range at least
284+
// from 6.0e-13 to 4.9e-8 ms, and in multiplayer replays at least from 0.333 ms to 3.333 ms.)
285+
const extraTolerance = this.rides.length > 1 ? 3.5 : 1.0e-7;
286+
const rideHasLastFrameEvent = lastFrameTimes.map((lastFrameTime, idx) => {
287+
const lastEventTime = lastEventTimes[idx];
288+
return lastEventTime ? lastFrameTime <= lastEventTime + FrameDuration + extraTolerance : false;
289+
});
261290

262-
// If event difference to frame time is >1 frames of time, probably not finished?
263-
if (maxFrameTime > maxEventTime + 33.333) {
291+
// If the highest event time is not within ~1 frame of time from the end, probably not finished?
292+
const finalEventIdx = lastEventTimes.lastIndexOf(finalEventTime);
293+
if (!rideHasLastFrameEvent[finalEventIdx]) {
264294
return {
265295
finished: false,
266296
reason: ReplayFinishStateReason.FrameDifference,
267-
time: Math.round(maxFrameTime),
297+
time: Math.round(finalFrameTime),
268298
};
269299
}
270300

271-
// Otherwise probably finished?
301+
// For multiplayer replays where one player waits at the flower for the other to take an apple.
302+
let waitingAtFlower = false;
303+
let endsWithAppleTake = false;
304+
305+
let isFinished = false;
306+
for (const [idx, ride] of this.rides.entries()) {
307+
// Potentially finished, if ride ends in a Touch or Apple event.
308+
const lastEvent = lastEvents[idx];
309+
if (lastEvent && rideHasLastFrameEvent[idx] && !isFinished) {
310+
if (lastEvent.type === EventType.Touch) {
311+
if (
312+
ride.events.length >= 2 &&
313+
ride.events[ride.events.length - 2].type === EventType.Touch &&
314+
ride.events[ride.events.length - 2].time !== lastEvent.time
315+
) {
316+
// Probably ended at flower, but not all apples were taken.
317+
isFinished = false;
318+
waitingAtFlower = true;
319+
} else {
320+
// Otherwise probably finished, but false positives are possible (e.g., dying to killer).
321+
isFinished = true;
322+
}
323+
} else if (lastEvent.type === EventType.Apple) {
324+
endsWithAppleTake = true;
325+
if (ride.events.length >= 3) {
326+
const endAppleCount = ride.events.filter(
327+
(event) => event.type === EventType.Apple && event.time === lastEvent.time,
328+
).length;
329+
const endTouchEventCount = ride.events.filter(
330+
(event) => event.type === EventType.Touch && event.time === lastEvent.time,
331+
).length;
332+
if (endTouchEventCount >= endAppleCount + 1) {
333+
// Apple(s) and flower taken at the same time. N apples and a flower will
334+
// generate (at least) N+1 Touch events, followed by N Apple events.
335+
isFinished = true;
336+
}
337+
}
338+
}
339+
}
340+
}
341+
342+
// Multiplayer finish.
343+
if (waitingAtFlower && endsWithAppleTake) isFinished = true;
344+
272345
return {
273-
finished: true,
274-
reason: ReplayFinishStateReason.Touch,
275-
time: Math.floor(maxEventTime),
346+
finished: isFinished,
347+
reason: isFinished ? ReplayFinishStateReason.Touch : ReplayFinishStateReason.NoTouch,
348+
time: isFinished && finalEventTime ? Math.floor(finalEventTime) : Math.round(finalFrameTime),
276349
};
277350
}
278351

0 commit comments

Comments
 (0)