Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion babel.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
module.exports = {
presets: ['@rnx-kit/babel-preset-metro-react-native'],
plugins: ['react-native-paper/babel', 'react-native-reanimated/plugin'],
plugins: [
'react-native-paper/babel',
'react-native-worklets-core/plugin',
'react-native-reanimated/plugin',
],
env: {
production: {
plugins: ['transform-remove-console'],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"react-native-svg": "^13.9.0",
"react-native-vector-icons": "^9.1.0",
"react-native-webview": "^12.0.2",
"react-native-worklets-core": "1.6.3",
"realm": "^12.2.1",
"solution-analyzer": "^0.1.6",
"uuid": "^9.0.0"
Expand Down
10 changes: 9 additions & 1 deletion src/__mocks__/globalMock.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

jest.mock('react-native-worklets-core', () => ({
createWorkletContext: () => ({
runAsync: fn => Promise.resolve(fn()),
runSync: fn => fn(),
}),
runOnJS: fn => fn,
}));

jest.mock('react-native-webview', () => {
const { View } = require('react-native');
return {
Expand All @@ -26,4 +34,4 @@ jest.mock('react-native-fs', () => ({
unlink: jest.fn(),
writeFile: jest.fn(),
}));
export const fs = jest.mocked(actualfs);
export const fs = jest.mocked(actualfs);
2 changes: 2 additions & 0 deletions src/lib/reconstructions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function reconstructionFor(
scramble: STIF.Algorithm,
moves: STIF.TimestampedMove[],
) {
'worklet';
return {
using: (method: SolutionMethod) =>
__reconstructionFor(scramble, moves, method),
Expand All @@ -24,6 +25,7 @@ function __reconstructionFor(
moves: STIF.TimestampedMove[],
method: SolutionMethod,
) {
'worklet';
const breakdown = analyzeSolution(
scramble.join(' '),
moves.map(v => v.m).join(' '),
Expand Down
67 changes: 67 additions & 0 deletions src/lib/recordings/__tests__/parseReconstructionAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2023 Joseph Hale <me@jhale.dev>
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

import { STIF } from '../../stif';
import { parseReconstruction } from '../parseReconstruction';
import { parseReconstructionAsync } from '../parseReconstructionAsync';

const rcAttempt =
require('../__fixtures__/rubiks_connected_attempt.json') as STIF.Attempt;
const rcRecording =
require('../__fixtures__/rubiks_connected_recording.json') as STIF.SolveRecording;

describe('parseReconstructionAsync', () => {
it('resolves with the same result as parseReconstruction for a Rubiks Connected attempt', async () => {
const attempt = rcAttempt;
const scramble = attempt.solutions[0].scramble;
const expected = parseReconstruction(rcRecording, scramble, attempt.timerStart);
const actual = await parseReconstructionAsync(
rcRecording,
scramble,
attempt.timerStart,
);
expect(actual).toEqual(expected);
});

it('resolves with the same result as parseReconstruction for a Particula 2x2x2', async () => {
const attempt =
require('../__fixtures__/particula_2x2x2_attempt.json') as STIF.Attempt;
const recording =
require('../__fixtures__/particula_2x2x2_recording.json') as STIF.SolveRecording;
const scramble = attempt.solutions[0].scramble;
const expected = parseReconstruction(recording, scramble, attempt.timerStart);
const actual = await parseReconstructionAsync(
recording,
scramble,
attempt.timerStart,
);
expect(actual).toEqual(expected);
});

it('resolves with the same result as parseReconstruction for a Particula 3x3x3', async () => {
const attempt =
require('../__fixtures__/particula_3x3x3_attempt.json') as STIF.Attempt;
const recording =
require('../__fixtures__/particula_3x3x3_recording.json') as STIF.SolveRecording;
const scramble = attempt.solutions[0].scramble;
const expected = parseReconstruction(recording, scramble, attempt.timerStart);
const actual = await parseReconstructionAsync(
recording,
scramble,
attempt.timerStart,
);
expect(actual).toEqual(expected);
});

it('resolves with an empty array when there is no message stream', async () => {
const actual = await parseReconstructionAsync(
{ smartPuzzle: rcRecording.smartPuzzle, stream: [] },
rcAttempt.solutions[0].scramble,
rcAttempt.timerStart,
);
expect(actual).toEqual([]);
});
});
1 change: 1 addition & 0 deletions src/lib/recordings/parseReconstruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function parseReconstruction(
startTime: number = 0,
method: SolutionMethod = 'CFOP',
) {
'worklet';
const moves = parseTimestampedMoves(recording, startTime);
const rawReconstruction = reconstructionFor(scramble, moves).using(method);
const reconstruction = rawReconstruction.map(phase => {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/recordings/parseReconstructionAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2023 Joseph Hale <me@jhale.dev>
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

import { createWorkletContext, runOnJS } from 'react-native-worklets-core';

import { SolutionMethod } from '../reconstructions';
import { STIF } from '../stif';
import { parseReconstruction } from './parseReconstruction';

const ReconstructionContext = createWorkletContext('ReconstructionContext');

export type ReconstructionPhase = ReturnType<
typeof parseReconstruction
>[number];

/**
* Parses the BLE event stream into a reconstruction asynchronously on a
* dedicated background thread, keeping the UI responsive while the
* CPU-intensive solution analysis runs.
*/
export function parseReconstructionAsync(
recording: STIF.SolveRecording,
scramble: STIF.Algorithm,
startTime: number = 0,
method: SolutionMethod = 'CFOP',
): Promise<ReconstructionPhase[]> {
return new Promise<ReconstructionPhase[]>((resolve, reject) => {
ReconstructionContext.runAsync(() => {
'worklet';
try {
const result = parseReconstruction(
recording,
scramble,
startTime,
method,
);
runOnJS(resolve)(result);
} catch (e) {
runOnJS(reject)(e);
}
});
});
}
2 changes: 2 additions & 0 deletions src/lib/recordings/parseTimestampedMoves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function parseTimestampedMoves(
recording: STIF.SolveRecording,
startTime: number = 0,
): STIF.TimestampedMove[] {
'worklet';
return adjustTimestamps(parseMoves(recording))
.relativeTo(startTime)
.sort((a, b) => a.t - b.t);
Expand All @@ -30,6 +31,7 @@ export function parseTimestampedMoves(
export function compressDoubleTurns(
moves: STIF.TimestampedMove[],
): STIF.TimestampedMove[] {
'worklet';
const window = moves[moves.length - 1].t / moves.length;

const movesMatch = (m1: string | null, m2: string | null) =>
Expand Down
96 changes: 57 additions & 39 deletions src/ui/components/PracticeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ import {
useAttemptCreator,
useSolveRecordingCreator,
} from '../../persistence/hooks';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';

import { Attempt } from '../../lib/stif/wrappers';
import { GeneratedScramble } from './scrambles/types';
import InspectionTimer from './inspection/InspectionTimer';
import { STIF } from '../../lib/stif';
import ScramblingView from './scrambles/ScramblingView';
import SolveTimer from './SolveTimer';
import { parseReconstruction } from '../../lib/recordings/parseReconstruction';
import { parseReconstructionAsync } from '../../lib/recordings/parseReconstructionAsync';
import { useCompetitiveEvent } from '../hooks/useCompetitiveEvent';

enum TimerState {
Expand Down Expand Up @@ -109,58 +109,76 @@ export default function PracticeView() {
if (didNotStart) {
console.log('DNF Detected');
// TODO Ensure DNFs are handled correctly.
const attempt = assembleAttempt();
persistAttempt(attempt);
setTimerState(TimerState.SCRAMBLING);
handleSolveComplete();
} else {
nextTimerState();
}
}

function handleSolveComplete() {
const attempt = assembleAttempt();
persistAttempt(attempt);
nextTimerState();
}

function assembleAttempt() {
const handleSolveComplete = useCallback(() => {
const now = new Date().getTime();
const didNotStart = timerStart < inspectionStart;
const attempt = new AttemptBuilder()
.setEvent(event)
.setInspectionStart(inspectionStart)
.setTimerStart(didNotStart ? now : timerStart)

// Capture state before transitioning so async code uses the right snapshot.
const capturedWipSolutions = wipSolutions;
const capturedInspectionStart = inspectionStart;
const capturedTimerStart = timerStart;
const capturedEvent = event;

// Stop BLE subscriptions and snapshot the recordings synchronously so
// no new messages are added after the solve ends.
const capturedRecordings = capturedWipSolutions.map(wip => {
if (wip.messageSubscription) {
wip.messageSubscription.remove();
}
return wip.messages?.build();
});

// Transition the UI immediately so the user sees no lag.
setTimerState(TimerState.SCRAMBLING);

// Build and persist the attempt in the background.
const didNotStart = capturedTimerStart < capturedInspectionStart;
const attemptBuilder = new AttemptBuilder()
.setEvent(capturedEvent)
.setInspectionStart(capturedInspectionStart)
.setTimerStart(didNotStart ? now : capturedTimerStart)
.setTimerStop(now);
wipSolutions
.map(wip => {
const recording = wip.messages?.build();

Promise.all(
capturedWipSolutions.map(async (wip, idx) => {
const recording = capturedRecordings[idx];
if (recording) {
const reconstruction = parseReconstruction(
const reconstruction = await parseReconstructionAsync(
recording,
wip.scramble.algorithm,
timerStart,
capturedTimerStart,
);
reconstruction.forEach(phase => wip.builder.addSolutionPhase(phase));
}
if (wip.messageSubscription) {
wip.messageSubscription.remove();
}
return wip.builder.build();
}),
)
.then(solutions => {
solutions.forEach(solution => attemptBuilder.addSolution(solution));
const attempt = attemptBuilder.build();
createAttempt(attempt);
setLastAttempt(new Attempt(attempt));
capturedWipSolutions.forEach((wip, idx) => {
const recording = capturedRecordings[idx];
if (recording) {
createRecording(attempt.solutions[idx].id, recording);
}
});
})
.forEach(solution => attempt.addSolution(solution));
return attempt.build();
}

function persistAttempt(attempt: STIF.Attempt) {
createAttempt(attempt);
setLastAttempt(new Attempt(attempt));
wipSolutions.map((wip, idx) => {
const recording = wip.messages?.build();
if (recording) {
createRecording(attempt.solutions[idx].id, recording);
}
});
}
.catch(e => console.error('Failed to assemble or persist attempt', e));
}, [
wipSolutions,
inspectionStart,
timerStart,
event,
createAttempt,
createRecording,
]);

return (
<View style={styles.container}>
Expand Down
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8485,6 +8485,13 @@ react-native-webview@^12.0.2:
escape-string-regexp "2.0.0"
invariant "2.2.4"

react-native-worklets-core@1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz#e95d879b28890bf27f019797aa3d8538532b4f4e"
integrity sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA==
dependencies:
string-hash-64 "^1.0.3"

react-native@>=0.68:
version "0.72.5"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"
Expand Down