Skip to content
Merged
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
39 changes: 39 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Deploy to GitHub Pages

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Allow one concurrent deployment; cancel superseded runs.
concurrency:
group: pages
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
- uses: actions/configure-pages@v5
- name: Upload site artifact
uses: actions/upload-pages-artifact@v3
with:
# Publish the repository root; the app is a static site with no build step.
path: .
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
npm-debug.log*
.DS_Store
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ or use the Live Server extension in VS Code. Then open:
http://localhost:8080
```

## Tests

The app itself ships with no build step, but the pure cube-state logic in
`src/cube-logic.js` (validation, parity, scramble generation, move helpers) has a
Node test suite. It runs in CI on every push and can be run locally:

```bash
npm install # dev-only: installs the cube.js engine and the test runner
npm test # node --test
```

## Keyboard controls

| Key | Action |
Expand Down Expand Up @@ -82,10 +93,14 @@ cubeflow/
β”œβ”€β”€ styles.css
β”œβ”€β”€ manifest.webmanifest
β”œβ”€β”€ sw.js
β”œβ”€β”€ package.json # dev tooling only (test runner); the app needs no build
β”œβ”€β”€ src/
β”‚ β”œβ”€β”€ app.js
β”‚ β”œβ”€β”€ cube-renderer.js
β”‚ β”œβ”€β”€ app.js # UI, state, and event wiring
β”‚ β”œβ”€β”€ cube-logic.js # pure cube-state logic (no DOM/Three), shared with tests
β”‚ β”œβ”€β”€ cube-renderer.js # Three.js 3D rendering
β”‚ └── solver-worker.js
β”œβ”€β”€ test/
β”‚ └── cube-logic.test.mjs
β”œβ”€β”€ assets/
β”‚ └── icon.svg
└── .github/workflows/pages.yml
Expand Down
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "cubeflow",
"version": "1.0.0",
"description": "Accessible browser-only 3x3 Rubik's Cube trainer and guided solver.",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "node --test"
},
"devDependencies": {
"cubejs": "1.3.2"
}
}
121 changes: 36 additions & 85 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { CubeRenderer, STICKER_COLORS } from './cube-renderer.js';
import {
SOLVED_STATE,
FACE_ORDER,
CENTER_INDICES,
countColors,
inverseMove,
applyMoveToState,
generateScramble,
validatePhysical
} from './cube-logic.js';

const Cube = window.Cube;
const SOLVED_STATE = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB';
const FACE_ORDER = ['U', 'R', 'F', 'D', 'L', 'B'];
const CENTER_INDICES = new Set([4, 13, 22, 31, 40, 49]);
const FACE_NAMES = {
U: 'Up', R: 'Right', F: 'Front', D: 'Down', L: 'Left', B: 'Back'
};
Expand Down Expand Up @@ -172,14 +179,6 @@ function colorStyle(letter) {
return `--sticker-color:${color.css};--label-color:${color.label}`;
}

function countColors(state = facelets) {
const counts = Object.fromEntries(FACE_ORDER.map((face) => [face, 0]));
[...state].forEach((letter) => {
if (counts[letter] !== undefined) counts[letter] += 1;
});
return counts;
}

function renderPalette() {
elements.palette.replaceChildren();
FACE_ORDER.forEach((letter) => {
Expand All @@ -203,7 +202,7 @@ function renderPalette() {
}

function renderCounts() {
const counts = countColors();
const counts = countColors(facelets);
elements.counts.replaceChildren();
FACE_ORDER.forEach((letter) => {
const color = STICKER_COLORS[letter];
Expand Down Expand Up @@ -290,68 +289,34 @@ function paintSticker(index) {
setValidation('neutral', 'Cube changed', 'Continue matching your real cube, then run the check.');
}

function isPermutation(array, length) {
return Array.isArray(array)
&& array.length === length
&& new Set(array).size === length
&& array.every((value) => Number.isInteger(value) && value >= 0 && value < length);
}

function permutationParity(array) {
let inversions = 0;
for (let i = 0; i < array.length; i += 1) {
for (let j = i + 1; j < array.length; j += 1) {
if (array[i] > array[j]) inversions += 1;
function validationMessage(result) {
switch (result.code) {
case 'count': {
const details = FACE_ORDER
.filter((face) => result.counts[face] !== 9)
.map((face) => `${STICKER_COLORS[face].name}: ${result.counts[face]}`)
.join(', ');
return `Each color needs nine stickers. Check ${details}.`;
}
case 'center':
return 'A center color was changed. Reset the cube orientation and try again.';
case 'permutation':
return 'Some corner or edge color combinations cannot exist on a real cube. Recheck the affected pieces.';
case 'corner-orientation':
return 'A corner appears twisted incorrectly. Recheck all three stickers on each corner.';
case 'edge-orientation':
return 'An edge appears flipped incorrectly. Recheck the two stickers on each edge.';
case 'parity':
return 'Two pieces appear swapped. Recheck the color entry around the entire cube.';
default:
return 'This color arrangement is not a valid physical cube. Recheck the stickers and orientation.';
}
return inversions % 2;
}

function inspectState(state = facelets) {
const counts = countColors(state);
const countProblems = FACE_ORDER.filter((face) => counts[face] !== 9);
if (countProblems.length) {
const details = countProblems.map((face) => `${STICKER_COLORS[face].name}: ${counts[face]}`).join(', ');
return { valid: false, message: `Each color needs nine stickers. Check ${details}.` };
}

for (let face = 0; face < 6; face += 1) {
const expected = FACE_ORDER[face];
if (state[face * 9 + 4] !== expected) {
return { valid: false, message: 'A center color was changed. Reset the cube orientation and try again.' };
}
}

try {
const cube = Cube.fromString(state);
const data = cube.toJSON();
if (!isPermutation(data.cp, 8) || !isPermutation(data.ep, 12)) {
return { valid: false, message: 'Some corner or edge color combinations cannot exist on a real cube. Recheck the affected pieces.' };
}
if (data.co.some((value) => value < 0 || value > 2) || data.co.reduce((sum, value) => sum + value, 0) % 3 !== 0) {
return { valid: false, message: 'A corner appears twisted incorrectly. Recheck all three stickers on each corner.' };
}
if (data.eo.some((value) => value < 0 || value > 1) || data.eo.reduce((sum, value) => sum + value, 0) % 2 !== 0) {
return { valid: false, message: 'An edge appears flipped incorrectly. Recheck the two stickers on each edge.' };
}
if (permutationParity(data.cp) !== permutationParity(data.ep)) {
return { valid: false, message: 'Two pieces appear swapped. Recheck the color entry around the entire cube.' };
}
return { valid: true, cube, solved: cube.isSolved() };
} catch (error) {
return { valid: false, message: 'This color arrangement is not a valid physical cube. Recheck the stickers and orientation.' };
}
}

function applyMoveToState(state, move) {
const cube = Cube.fromString(state);
cube.move(move);
return cube.asString();
}

function inverseMove(move) {
if (move.includes('2')) return move;
return move.includes("'") ? move[0] : `${move[0]}'`;
const result = validatePhysical(Cube, state);
if (result.valid) return { valid: true, cube: result.cube, solved: result.solved };
return { valid: false, message: validationMessage(result) };
}

async function performMove(move, { source = 'button', record = true } = {}) {
Expand All @@ -364,7 +329,7 @@ async function performMove(move, { source = 'button', record = true } = {}) {
}

if (record) history.push(facelets);
const nextState = applyMoveToState(facelets, move);
const nextState = applyMoveToState(Cube, facelets, move);
isBusy = true;
const duration = Number(elements.speedSelect.value || 580);
if (renderer) await renderer.animateMove(move, nextState, duration);
Expand Down Expand Up @@ -429,20 +394,6 @@ function undo() {
announce('Last change undone.');
}

function generateScramble(length = 20) {
const faces = ['U', 'R', 'F', 'D', 'L', 'B'];
const suffixes = ['', "'", '2'];
const moves = [];
let previousFace = '';
while (moves.length < length) {
const face = faces[Math.floor(Math.random() * faces.length)];
if (face === previousFace) continue;
previousFace = face;
moves.push(`${face}${suffixes[Math.floor(Math.random() * suffixes.length)]}`);
}
return moves;
}

function setSolved() {
history.push(facelets);
setState(SOLVED_STATE, { invalidate: true });
Expand Down Expand Up @@ -539,7 +490,7 @@ function loadSolution(moves) {
solutionStates = [solutionStartState];
let state = solutionStartState;
moves.forEach((move) => {
state = applyMoveToState(state, move);
state = applyMoveToState(Cube, state, move);
solutionStates.push(state);
});
solutionIndex = 0;
Expand Down
97 changes: 97 additions & 0 deletions src/cube-logic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Pure cube-state logic shared by the app UI and the test suite.
// This module has no DOM or Three.js dependencies, so it runs unchanged in the
// browser and under Node (see test/cube-logic.test.mjs). Functions that need a
// cube engine take the `Cube` implementation as their first argument, so the
// browser can pass the global cube.js build and tests can pass the npm package.

export const SOLVED_STATE = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB';
export const FACE_ORDER = ['U', 'R', 'F', 'D', 'L', 'B'];
export const CENTER_INDICES = new Set([4, 13, 22, 31, 40, 49]);

export function countColors(state) {
const counts = Object.fromEntries(FACE_ORDER.map((face) => [face, 0]));
for (const letter of state) {
if (counts[letter] !== undefined) counts[letter] += 1;
}
return counts;
}

export function isPermutation(array, length) {
return Array.isArray(array)
&& array.length === length
&& new Set(array).size === length
&& array.every((value) => Number.isInteger(value) && value >= 0 && value < length);
}

export function permutationParity(array) {
let inversions = 0;
for (let i = 0; i < array.length; i += 1) {
for (let j = i + 1; j < array.length; j += 1) {
if (array[i] > array[j]) inversions += 1;
}
}
return inversions % 2;
}

export function inverseMove(move) {
if (move.includes('2')) return move;
return move.includes("'") ? move[0] : `${move[0]}'`;
}

export function applyMoveToState(Cube, state, move) {
const cube = Cube.fromString(state);
cube.move(move);
return cube.asString();
}

// `random` is injectable so tests can produce deterministic scrambles.
export function generateScramble(length = 20, random = Math.random) {
const suffixes = ['', "'", '2'];
const moves = [];
let previousFace = '';
while (moves.length < length) {
const face = FACE_ORDER[Math.floor(random() * FACE_ORDER.length)];
if (face === previousFace) continue;
previousFace = face;
moves.push(`${face}${suffixes[Math.floor(random() * suffixes.length)]}`);
}
return moves;
}

// Validates that a 54-character facelet string represents a physically solvable
// cube. Returns a machine-readable `code` so the UI can attach its own copy and
// tests can assert without matching prose. On success the live `cube` instance
// and its solved flag are included.
export function validatePhysical(Cube, state) {
const counts = countColors(state);
const hasCountProblem = FACE_ORDER.some((face) => counts[face] !== 9);
if (hasCountProblem) return { valid: false, code: 'count', counts };

for (let face = 0; face < FACE_ORDER.length; face += 1) {
if (state[face * 9 + 4] !== FACE_ORDER[face]) {
return { valid: false, code: 'center', counts };
}
}

try {
const cube = Cube.fromString(state);
const data = cube.toJSON();
if (!isPermutation(data.cp, 8) || !isPermutation(data.ep, 12)) {
return { valid: false, code: 'permutation', counts };
}
if (data.co.some((value) => value < 0 || value > 2)
|| data.co.reduce((sum, value) => sum + value, 0) % 3 !== 0) {
return { valid: false, code: 'corner-orientation', counts };
}
if (data.eo.some((value) => value < 0 || value > 1)
|| data.eo.reduce((sum, value) => sum + value, 0) % 2 !== 0) {
return { valid: false, code: 'edge-orientation', counts };
}
if (permutationParity(data.cp) !== permutationParity(data.ep)) {
return { valid: false, code: 'parity', counts };
}
return { valid: true, code: 'ok', counts, cube, solved: cube.isSolved() };
} catch (error) {
return { valid: false, code: 'unsolvable', counts };
}
}
Loading
Loading