diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 6141406..0993c2e 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -8,6 +8,10 @@ releases may break, and every breaking change is recorded in this file ## [0.6.1] — 2026-10-15 +> Version note: `v0.6.0` was cut as a draft release and its tag pattern was +> retired (immutable tombstone) before publication; everything it contained +> shipped in `0.6.1`. There is no published 0.6.0. + The engine learns when not to answer: selective-prediction evidence floors and elicited abstention join the calibration stack, the corpus toolchain becomes a first-class item generator crate, and the diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 28607fc..f90e70a 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -4,7 +4,9 @@ | Version | Supported | | ------- | --------- | -| 0.1.x | yes | +| 0.6.x | yes | +| 0.5.x | yes | +| < 0.5 | no | ## Reporting a vulnerability diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 536273e..800a6e1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,6 +13,7 @@ in a committed phase, **contract** = frozen v1 surface. | Schema adapters | `opencodifier-schema` | core | | Inference runtime | `opencodifier-runtime` | core (+ `ort` behind one feature) | | Decision model | `opencodifier-model` | engine, runtime, core | +| Item generator | `opencodifier-itemgen` | core, engine | | Interfaces | `opencodifier-cli`, `-http`, `-mcp`, `-wasm`, `-ffi` | engine/model, schema | The dependency arrows only ever point toward `core`. Nothing in the diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4436a83..39597f7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -148,7 +148,7 @@ re-ordering, candidate narrowing subset-safety. Fixtures under | `runtime-onnx-dynamic` | opencodifier-runtime | ort `load-dynamic` | | `llamacpp` | opencodifier-model | ureq (HTTP client only, D26) | | `tokenizers` | opencodifier-model | `tokenizers` 0.22 + `fancy-regex` (Kai reference encoder, D33) | -| `http` | opencodifier-http | axum 0.9, tokio | +| `http` | opencodifier-http | axum 0.8, tokio (pin corrected by D12) | | `mcp` | opencodifier-mcp | rmcp 2.2 (D1) | | `cli` | opencodifier-cli | clap v4 | | `wasm` | opencodifier-wasm | wasm-bindgen, getrandom js | diff --git a/docs/PLAN.md b/docs/PLAN.md index 6679788..372f7b0 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -62,7 +62,9 @@ Remaining risks tracked in §6 below. | 19 | Adoption + integration documentation | **done** (2026-10-04) — `docs/INTEGRATIONS.md` + D30/D31 + 19e Amortyx E2E | | 20 | Coverage restoration: the escalation core | **done** (2026-10-08) — 20a ratchet done (D36); 2026-10-07 census re-based on the pinned image (**98.03 % in-image DA**, 350-line residual; the 980-line "executor debt" was a local-mapping artifact), floor 89.0 → 97.5, COVERAGE.md regenerated, 20h done; 20g tranches 1–3 done 2026-10-08 (cli ladder shell → itemgen probe/guard arms + UTF-8 boundary → macjev fall-through): **98.39 %** (18 187 / 18 485), residual 298, every line in a named infeasible class — unit-reachable pool exhausted, D36 ratchet retired at the reachable ceiling, floor rests at 97.5 fixed | | 21 | Site WCAG 2.1 AAA + progressive enhancement | **opened** (2026-10-06) — audit + fixes landed; deploy + X/Discord pending | -| 22 | Docs freshness sweep | **opened** (2026-10-06) | +| 22 | Docs freshness sweep | **in progress** (2026-10-06 opened; 2026-10-15 sweep: SECURITY matrix, D11 axum pin, §36 route note, itemgen in ARCHITECTURE, site counts, wiki cross-checks) | + +Handoff of record: `docs/handoffs/PROGRAM_HANDOFF_2026-10-15.md` (state, next steps, ranked improvements, roadmap incl. the vision rung; supersedes no record, organizes them). | 23 | v0.5.0 release cut | **done** (2026-10-08) — tag `v0.5.0` → `0fd6a154`, 9-leg matrix rebuilt single-tree with one consistent attestation set; releases on GitForge + GitHub (19 assets, 69.4 MB) | | 24 | RESEARCH §15.6 enhancement program | **in progress** (2026-10-07) — items 1–4, 8–10 + §15.8 fold-ins done (ledger: RESEARCH §15.6.1); items 5/6 sized, model-rung options, deferred per the §15.6 note | diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 49dffe3..414fb85 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -1488,6 +1488,13 @@ POST /v1/systemone The compatibility endpoint must emulate the supported Jev/System One schema, while the native `/v1/decide` API remains the canonical OpenCodifier API. +*Amendment (2026-10-15): shipped reality — there is no `/v1/systemone` +route. Jev/System One compatibility lives in `opencodifier-schema` as a +wire adapter (Jev in, canonical IR out), and the HTTP surface is +`POST /v1/decide` (+ `/v1/chat/completions`, `/v1/messages`, +`/v1/batch`). The adapter-normalizes-into-the-IR rule supersedes a +dedicated compat endpoint; see docs/DECISIONS.md D12.* + --- # 37. Local Server diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 96963a6..10e33e1 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -73,12 +73,15 @@ opencodifier/ │ ├── *-core.js # one DOM-free, node-testable core per game │ │ # (pong, life, pacman, racing, fighter, quiz, │ │ # tower, antcolony, civ60, traffic, spacecraft, -│ │ # dilemma, rpsls, door) — seeded, deterministic +│ │ # dilemma, rpsls, door, cube + cube-n, clock, +│ │ # pyraminx) — seeded, deterministic; the twisty +│ │ # cores carry proved solvers, not heuristics │ ├── .js # per-game rendering shells (canvas/DOM chrome; │ │ # rendering never touches an rng stream) │ ├── playground.html # merged playground: maze demo + game library │ └── tests/ # node --test suites: board page, maze -│ # core/shell/deep-link, SDK games (20 files) +│ # core/shell/deep-link, SDK games, cube cores, +│ # clock (27 files) └── docs/ # PLANNING.md (founding), PLAN.md (live plan), … # RELEASE_NOTES_TEMPLATE.md is the release-notes # skeleton; `just release-notes` fills its diff --git a/docs/handoffs/PROGRAM_HANDOFF_2026-10-15.md b/docs/handoffs/PROGRAM_HANDOFF_2026-10-15.md new file mode 100644 index 0000000..8e39ba4 --- /dev/null +++ b/docs/handoffs/PROGRAM_HANDOFF_2026-10-15.md @@ -0,0 +1,173 @@ +# Program Handoff — 2026-10-15 + +**Status:** handoff document. It organizes the state of record and names the +next steps with their stopping conditions. Every factual claim here is a +pointer to a record of record (listed per item); nothing in this document is +itself primary evidence. Times are US Central. + +**Branch of note:** `site/games-audit-2026-10` (pushed to GitForge and +origin) carries the 2026-10-15 site work: cube overhaul (`ce9d65a`) and the +docs freshness sweep (`553cd28`). Site flow: edits on branch → Cloudflare +Pages preview → human merges the PR. + +--- + +## 1. State of record + +**Release.** `v0.6.1` (2026-10-15): selective prediction end to end, +`opencodifier-itemgen` as a crate, corpus-composition question closed, first +gated weights on HuggingFace. `v0.6.0` is an unpublished tombstone (tag +pattern retired); `.github/CHANGELOG.md` says so explicitly. Coverage 98.39 % +in-image DA; CI of record is GitForge on the fedora remote. + +**Model program** (`docs/TRAINING.md`, all verdicts recorded there): + +| Arm | Verdict | Numbers of record | +|---|---|---| +| E1-E (2B, iterb scaffold 178k) | **GATE_PASS ×2 — release candidate** | suite 0.8083, JevBench 0.6840, ECE 0.127 (T=3.362) | +| t33p (2B, merged-v6-full 307k) | GATE_PASS ×2 — evidence, not candidate | suite 0.6917, board 0.6580, lane drop −3.4 (vs E1-E's −12.4) | +| T88 imatrix Q4_K_M quant | **FAIL all three gates** | recorded 2026-10-15, 10:20 AM | +| MiniCPM5 GGUF rung | CLOSED no-go | adapter lane-bound; size/speed legs win | +| Corpus-volume lever | **CLOSED** | v5 catastrophic → v6 gate-passing → scaffold alone best; "gated volume ≠ transfer" | + +The two legs of the release gate: suite ≥ 0.68 AND JevBench ≥ 0.6494 +(`docs/RELEASE_POLICY.md`; [[Models]] on the wiki mirrors it). Weights +publish only gate-gated to HuggingFace (`TaskWizerAI`), open Apache-2.0. + +**Site / playground** (`site/`, branch `site/games-audit-2026-10`): + +- **Twisty Cubes 2×2–21×21** (`cube.html`, `cube.js`, `cube-core.js`, + `cube-n-core.js`): two verified cores under one facade. 3×3 keeps the + battle-tested LBL core; 2×2 has its own coach (layer build → (R′D′RD) + orientation → BFS over the 24 top permutations); 4×4–21×21 are validated + free play with piece-law checks on every move and SiGN wide turns. + Renderers: CSS-3D orbit at n ≤ 3, canvas net at n ≥ 4. Mirror/ghost/ + sudokube skins at 3×3. The long-standing T-perm typo (15 tokens) is fixed, + and the greedy phase-3 stall is replaced by exhaustive BFS. Tests: + cube-n-core 13, cube-core 10, playwright smoke 25/25. +- **Rubik's Clock** (`clock.html`, `clock.js`, `clock-core.js`): TNoodle-pinned + move table, rank-14 basis over Z₁₂, 12¹⁴ reachable states, **unique + solution** — solved by exact elimination, replay-verified; engine-free on + purpose (no ties exist to arbitrate). Tests: core 12/12, smoke 13/13. + Two shell bugs found by smoke and fixed: plan moves no longer invalidate + their own plan mid-step, and the boot chip now renders (and truthfully + reads "scrambled" — the page boots paused per the site convention). +- **Playground hub** (`playground.html`): truthful card badges — playable vs + "verified core is in test" (Pyraminx, Skewb) vs "designed, not faked" + (Megaminx, Square-1, Fisher, Windmill). +- **Docs sweep** (`553cd28`): SECURITY supported-matrix → 0.6.x; D11 axum pin + corrected to 0.8; PLANNING §36 amendment (no `/v1/systemone` route — Jev + compat ships as a schema adapter); itemgen added to ARCHITECTURE; site + counts corrected (27 test suites); PLAN phase 22 notes the sweep; + CHANGELOG explains the 0.6.0 tombstone. +- **Wiki** (GitHub wiki, pushed `ae89b4e`): 17 pages + footer/sidebar; + Roadmap now carries the vision rung (§3 below); Playground lists the cube + family and Clock truthfully. + +**Compute posture** (memory + `docs/handoffs/PLATFORM_DECISION_RUNTIME_ADOPTION_HANDOFF_2026-10-12.md`): +the T5500 is down; fedora carries the eval/training load through harness-jobs; +local hosts serve models; training is free-tier-first (Kaggle T4) with a ~$50 +Modal burst budget authorized. CI runs on GitForge, never local cargo. + +## 2. Immediate next steps (in order) + +1. **Integrate Pyraminx + Skewb** — cores are being built by the in-flight + agent (site/pyraminx-core.js exists; Skewb pending). Stopping condition: + both cores pass node suites (TNoodle/`jaap`-pinned move tables, seeded + solve sweeps), pages ship (`pyraminx.html`, `skewb.html`), playground + "soon" badges flip to playable. Do NOT fake a solver: if the generator + search can't prove a basis, the page ships free-play with laws validation, + like the big cubes. +2. **Games-audit PR through review** — push any remaining branch work, open + the CF Pages preview, and hand the operator the PR. Stopping condition: + human merge to main + production re-deploy. +3. **Board submission** — `docs/board-submission-draft.md` + 8 operator + questions are the blockers; the dominance verdict (composite plausible, + every-axis not yet) is recorded in `docs/BENCHMARKS.md`. Stopping + condition: operator answers → submission filed. +4. **#150 eval lane v2** — batching + sdpa with anchor-drift check. t33p's + −3.4 lane agreement (vs E1-E's −12.4) makes the lane-honesty question + sharper: the suite leg may be partially lane-specific. Stopping condition: + load-pinned determinism replay or an agreed tolerance, recorded in + `docs/TRAINING.md`. + +## 3. Areas of improvement (ranked) + +1. **Vision support — the operator requirement.** "It can be optional and a + separate rung/lever that routes only when needed, but we need vision + support to compete… And it needs to be comprehensive and fast." Design + shape (recorded on the wiki Roadmap, mirrors the binding rules): + - `ImageBackend` seam beside `InferenceBackend`/`EmbeddingBackend` in + `opencodifier-runtime`; ONNX behind the feature gate (D11); no Python + at runtime (§73). + - Rung position: above lexical/embedding, below the text decision model; + fires **only on image-bearing requests** — the default text path never + pays for it. + - Outputs are candidate-conditioned typed decisions (choice/boolean/ + score), never free-form generation; confidence passes the mandatory + calibration gate; cache keys carry vision-model + calibration versions. + - Models: small quantized vision encoders (SigLIP-class) on the ONNX CPU + path, explicit opt-in pull (release policy), verifier contract mirrored + from the text ladder (agree → accept, disagree → abstain). + - Open work: sizing + first parity fixtures (fixture-first, per the + model-import parity discipline in quality rules). +2. **Big-cube solver honesty.** 4×4–21×21 are free play by design. A + reduction solver is a real project; if wanted, size it as its own phase + (parity handling is the hard part) rather than diluting the coach. +3. **Megaminx / Square-1 / Fisher / Windmill.** Designed-but-not-faked on + the playground. Megaminx needs a dodecahedral core (12-color laws); + Square-1 needs a non-group-regular move algebra (shape-dependent). Both + deserve their own verified cores before any page ships. +4. **Per-rung calibration artifacts** (D25/F24 seam exists). E1-E's per-kind + T fit is the working pattern; ship rung artifacts only where the ECE gate + passes. +5. **Docs phase 22 residue.** The wiki cross-checks ride the same phase; + re-run the stale-doc probe after each release cut, not ad hoc. + +## 4. Roadmap + +- **Near (this cycle):** Pyraminx + Skewb pages; games-audit PR merged; board + submission filed; #150 lane v2 spec'd; vision rung sized with parity + fixtures named. +- **Mid:** vision rung implementation behind its feature gate; consensus-flip + patch / strands consensus pass (quality-first trainer lever); T3.4 4B + contingency if the 2B plateau holds; per-rung calibration artifacts where + gates pass; Megaminx core. +- **Far:** big-cube reduction solver; hosted tier per + `docs/HOSTED_TIER.md`; registry publication (explicit operator go, D24); + Square-1 core. + +**Deliberately not scheduled** (unchanged): default-on escalation ladder +(per-domain calibration precedes it), browser model path (D23), any +cloud/API-key dependency (the default posture is fully local and offline). + +## 5. Standing constraints (unchanged, binding) + +- Push order: `git push gitforge` first, then `origin`; pushed history is + immutable — fix forward, never force-push; never `git stash`. +- CI/CD through GitForge on fedora; a red GitHub Actions run is not a code + signal. Heavyweight checks via harness-jobs profiles. +- Never echo credentials (gitforge/Kaggle/HF/env files); `gitforge auth + --login` is the operator's credential path. +- Conventional commits, `Co-Authored-By: Claude` trailer; aegis baseline + regen rides the same commit as source edits. +- Site conventions: boot-paused games with truthful play buttons; accordion + exclusivity site-wide; stats overlay on every game; responsive rule for + every canvas; edits on branch → CF preview → human merge. +- Findings land in repo docs + commit + push before the turn ends; memory + holds pointers only. +- Reports in AM/PM 12-hour US Central. + +## 6. Where everything lives + +| Record | Path | +|---|---| +| Live plan + phase table | `docs/PLAN.md` | +| Binding decisions | `docs/DECISIONS.md` (D1–D37+) | +| Founding spec | `docs/PLANNING.md` (§§1–80) | +| Training record + verdicts | `docs/TRAINING.md` | +| Benchmark page of record | `docs/BENCHMARKS.md` | +| Release policy + notes flow | `docs/RELEASE_POLICY.md`, `.github/CHANGELOG.md` | +| Site release flow | `docs/SITE_RELEASE_FLOW.md` | +| Wiki | GitHub wiki (17 pages; clone at `/var/tmp/wiki-oc`) | +| Prior handoff (runtime adoption) | `docs/handoffs/PLATFORM_DECISION_RUNTIME_ADOPTION_HANDOFF_2026-10-12.md` | diff --git a/site/clock-core.js b/site/clock-core.js new file mode 100644 index 0000000..088ad74 --- /dev/null +++ b/site/clock-core.js @@ -0,0 +1,422 @@ +/* Rubik's Clock solver — core. + The WCA Rubik's Clock as 18 dials mod 12 (front 0..8 row-major from the + front, back 9..17 row-major from the BACK view, so the halves swap + under y2 exactly like the WCA scrambler's own state encoding). On top + of the moves: a validator that refuses unreachable states WITH the + named reason (dial range, the four corner-pair laws), a deterministic + solver that runs the published method shape — front edge wheels + + ALL, y2, back edge wheels + ALL, y2, then the four corner wheels — + with every amount solved exactly in Z_12 and every applied step + replay-verified through the public move algebra, seeded WCA-style + scrambles, and a solved verifier with two independent reads. + + The move table is DERIVED from the documented pin mechanism, never + hand-transcribed: each move names the pins that are UP, the front + dials that turn are the union of "the four dials around each up + pin", and the reverse side sees exactly the up-pin corner dials, + turned the opposite way. The derived table is checked at load + against the reference rows of the WCA scrambler itself. + + Sources for the mechanism, the notation and the method shape: + - WCA scrambler of record (move rows + scramble shape): + https://github.com/thewca/tnoodle-lib — + scrambles/src/main/java/org/worldcubeassociation/tnoodle/puzzle/ClockPuzzle.java + (the `moves` table and `generateRandomMoves`) + - Pin-config semantics ("UR = only UR pin is up", "that dial turns + only the 4 mini clocks around this pin", "All up: any dial turns + all 9 mini clocks", y2 = flip about the y axis): + https://www.speedsolving.com/wiki/index.php/Clock_notation + - Mechanism and method shape (front/back corner clocks connected, + Abelian group, 12^14 positions, beginner method outline): + https://www.jaapsch.net/puzzles/clock.htm + + The shell (clock.js) renders the dials; this file never touches the + DOM. */ + +(function () { + "use strict"; + const DK = globalThis.DecisionsSDK; + + /* ---------- dials: layout, corners, pin geometry ---------- */ + + const N = 18; + const FRONT = 0, BACK = 9; /* first index of each face's block */ + const SOLVED = new Array(N).fill(0); + + /* corner dial indices, front view: 0 UL, 2 UR, 6 DL, 8 DR */ + const FRONT_CORNERS = { UL: 0, UR: 2, DL: 6, DR: 8 }; + /* the same physical corner seen from the BACK view is mirrored + left-right: back index = 9 + 3*row + (2 - col). */ + function backIndexOf(frontCorner) { + const row = Math.floor(frontCorner / 3), col = frontCorner % 3; + return BACK + row * 3 + (2 - col); + } + const CORNER_PAIRS = { UL: [0, 11], UR: [2, 9], DL: [6, 17], DR: [8, 15] }; + + /* the four front dials around each corner pin (corner, two edges, + center) — the wiki's "the 4 mini clocks around this pin" */ + const AROUND = { + UL: [0, 1, 3, 4], + UR: [1, 2, 4, 5], + DL: [3, 4, 6, 7], + DR: [4, 5, 7, 8], + }; + + /* which pins are UP for each move (wiki: "UR = Only UR pin is up", + "U = Only UL and UR pins are up", "ALL = All pins are up") */ + const PINS_UP = { + UR: ["UR"], DR: ["DR"], DL: ["DL"], UL: ["UL"], + U: ["UL", "UR"], R: ["UR", "DR"], D: ["DR", "DL"], L: ["DL", "UL"], + ALL: ["UL", "UR", "DL", "DR"], + }; + + const MOVES = ["UR", "DR", "DL", "UL", "U", "R", "D", "L", "ALL"]; + + /* ---------- move table, derived then pinned to the reference ---------- */ + + /* Front dials turn +1 (clockwise from the front); the reverse side + turns the up-pin corner dials -1 (the same physical turn seen from + behind). Jaap: "the front and back corner clocks are connected" + and, for a corner turned next to an up button, "none of the clocks + on the reverse side move, except for the corner clocks." */ + function deriveMove(name) { + const v = new Array(N).fill(0); + const seen = {}; + for (const pin of PINS_UP[name]) { + for (const d of AROUND[pin]) { v[d] = 1; seen[d] = true; } + v[backIndexOf(FRONT_CORNERS[pin])] = -1; + } + return v; + } + + const MOVE_VEC = {}; + for (const m of MOVES) MOVE_VEC[m] = deriveMove(m); + + /* Reference rows transcribed from the WCA scrambler of record + (tnoodle-lib ClockPuzzle.java, the `moves` int[][]). The derived + table must equal these exactly — a mismatch is a load-time abort, + not a silently wrong puzzle. */ + const REFERENCE = { + UR: [0,1,1,0,1,1,0,0,0, -1,0,0,0,0,0,0,0,0], + DR: [0,0,0,0,1,1,0,1,1, 0,0,0,0,0,0,-1,0,0], + DL: [0,0,0,1,1,0,1,1,0, 0,0,0,0,0,0,0,0,-1], + UL: [1,1,0,1,1,0,0,0,0, 0,0,-1,0,0,0,0,0,0], + U: [1,1,1,1,1,1,0,0,0, -1,0,-1,0,0,0,0,0,0], + R: [0,1,1,0,1,1,0,1,1, -1,0,0,0,0,0,-1,0,0], + D: [0,0,0,1,1,1,1,1,1, 0,0,0,0,0,0,-1,0,-1], + L: [1,1,0,1,1,0,1,1,0, 0,0,-1,0,0,0,0,0,-1], + ALL:[1,1,1,1,1,1,1,1,1, -1,0,-1,0,0,0,-1,0,-1], + }; + for (const m of MOVES) { + if (MOVE_VEC[m].some((x, i) => x !== REFERENCE[m][i])) { + throw new Error("derived move table disagrees with the WCA reference for " + m); + } + } + + /* ---------- move token algebra ---------- */ + + /* wheel tokens: NAME [digits] SIGN ("UR3+", "DL+", "ALL8-", "UR4-"); + a bare sign means one hour; y2 flips the puzzle about the y axis. */ + const MOVE_RE = /^(UR|DR|DL|UL|U|R|D|L|ALL)(\d*)([+-])$/; + + function parseMove(tok) { + if (tok === "y2") return { name: "y2", amount: 0 }; + const m = MOVE_RE.exec(tok); + if (!m) throw new Error("bad move token: " + tok); + const digits = m[2] === "" ? 1 : Number(m[2]); + if (!Number.isInteger(digits) || digits < 0) { + throw new Error("bad amount in move token: " + tok); + } + const signed = m[3] === "+" ? digits : -digits; + return { name: m[1], amount: ((signed % 12) + 12) % 12 }; + } + + function applyVec(state, v, amount) { + return state.map((x, i) => ((x + amount * v[i]) % 12 + 12) % 12); + } + + function swapHalves(state) { + return state.slice(BACK).concat(state.slice(FRONT, BACK)); + } + + function applyMove(state, tok) { + const { name, amount } = parseMove(tok); + if (name === "y2") return swapHalves(state); + return applyVec(state, MOVE_VEC[name], amount); + } + + function applyAlg(state, alg) { + const toks = Array.isArray(alg) + ? alg + : String(alg).trim().split(/\s+/).filter(Boolean); + let s = state; + for (const tok of toks) s = applyMove(s, tok); + return s; + } + + /* the y2 image of a move vector: flipping the puzzle then applying a + move equals applying the swapped-half vector then flipping */ + const swapVec = (v) => v.map((_, i) => v[(i + BACK) % N]); + + /* ---------- validator: refuse unreachable states, with reasons ---------- */ + + /* Reachable set. The nine wheel generators (plus their y2 images) + span a rank-14 subgroup of Z_12^18 — elimination over the mod-12 + generator matrix leaves exactly four independent constraints, so + the puzzle has 12^14 reachable positions, which is Jaap's published + count. The four constraints say: at every physical corner, the + front dial and the back dial are negatives mod 12. Every state + passing them IS reachable (the law-legal set also has 12^14 + elements, and the reachable set is contained in it), and the + solver realizes that reachability constructively. */ + const PAIR_LAWS = ["UL", "UR", "DL", "DR"]; + + function validateState(state) { + const checks = []; + const add = (name, ok, reason) => checks.push({ name, ok, reason: ok ? "" : reason }); + + if (!Array.isArray(state) || state.length !== N) { + add("shape", false, "state must be " + N + " dial readings, front 0..8 then back 9..17"); + return { ok: false, checks }; + } + add("shape", true, ""); + + for (let i = 0; i < N; i++) { + const d = state[i]; + add("dial-" + i, Number.isInteger(d) && d >= 0 && d <= 11, + "dial " + i + " reads " + String(d) + " — every dial is an integer 0..11 (hours past 12, mod 12)"); + } + + for (const k of PAIR_LAWS) { + const [f, b] = CORNER_PAIRS[k]; + const sum = (((state[f] + state[b]) % 12) + 12) % 12; + add("corner-pair-" + k, sum === 0, + "corner " + k + ": front dial " + f + " (" + state[f] + ") + back dial " + b + + " (" + state[b] + ") = " + sum + " mod 12 — the two dials of one physical corner" + + " are negatives, so no pin-legal sequence reaches this state"); + } + + return { ok: checks.every((c) => c.ok), checks }; + } + + /* Two reads of "solved": the flat dial string and the per-face loop. + For an additive puzzle they reduce to the same arithmetic; both are + kept as independent code paths so a layout slip cannot pass one + while passing the other. */ + function verifySolved(state) { + const byDials = state.length === N && state.every((d) => d === 0); + let byFace = Array.isArray(state) && state.length === N; + for (let face = 0; face < 2 && byFace; face++) { + for (let i = 0; i < 9; i++) { + if (state[face * 9 + i] !== 0) { byFace = false; break; } + } + } + return { solved: byDials && byFace, byDials, byFace }; + } + + /* ---------- solved state, config, scrambles ---------- */ + + const DEFAULT_CONFIG = { + seed: "clock-wca", scrambleLen: 6, engineTies: true, + }; + + function normalizeConfig(raw) { + const c = raw || {}; + const num = (v, lo, hi, dflt) => (v === undefined || v === null + || Number.isNaN(Number(v))) ? dflt : DK.clampf(Number(v), lo, hi, dflt); + return { + seed: c.seed === undefined || c.seed === null ? DEFAULT_CONFIG.seed : String(c.seed), + /* wheel moves per side; the WCA scramble uses 9 distinct types + front (UR DR DL UL U R D L ALL) and 5 on the back */ + scrambleLen: Math.round(num(c.scrambleLen, 1, 9, DEFAULT_CONFIG.scrambleLen)), + engineTies: c.engineTies === undefined ? DEFAULT_CONFIG.engineTies : !!c.engineTies, + }; + } + + const clockStamp = (cfg) => "clock|" + cfg.seed + "|" + cfg.scrambleLen + + "|" + (cfg.engineTies ? 1 : 0); + + /* WCA-style scramble: front section (distinct move types, canonical + UR DR DL UL U R D L ALL order), y2, back section. Amounts follow + the scrambler of record: uniform -5..+5, sign from the raw draw + (ClockPuzzle.java: turn = nextInt(12)-5, clockwise = turn >= 0). */ + function makeScramble(cfg) { + const rng = DK.makeRng("clock-scramble:" + cfg.seed); + const pick = (n) => { + const idx = MOVES.map((_, i) => i); + for (let i = idx.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [idx[i], idx[j]] = [idx[j], idx[i]]; + } + return idx.slice(0, n).sort((a, b) => a - b).map((i) => MOVES[i]); + }; + const token = (name) => { + const raw = Math.floor(rng() * 12) - 5; + return name + Math.abs(raw) + (raw >= 0 ? "+" : "-"); + }; + const section = (n) => pick(n).map(token); + return section(cfg.scrambleLen).concat(["y2"], section(cfg.scrambleLen)); + } + + /* ---------- solver ---------- */ + + /* The published method shape, as the scrambler itself moves: + front edge wheels (U L R D ALL), y2, back edge wheels (U L R D + ALL), y2, then the four corner wheels (UL UR DL DR). 14 unknowns. + The clock is abelian and additive mod 12 (Jaap: "the order that + the moves are performed is unimportant"), so those 14 vectors form + a basis of the whole reachable subgroup (rank 14 — the same + elimination that proves the four pair laws): for every law-legal + state the linear system has exactly one solution, computed here by + Gauss-Jordan over Z_12. Every nonzero table entry is ±1, a unit + mod 12, so elimination never stalls on a non-invertible pivot. + + Termination is by construction, not by descent: the plan is a + fixed 14-slot sequence whose amounts are solved up front, and + every applied token is replayed through the public move algebra + with the running state checked for law-legality — a divergence or + an illegal intermediate refuses with a named error. */ + + const SOLVE_PLAN = [ + { phase: "edges-front", title: "front edge wheels (U L R D ALL)", + names: ["U", "L", "R", "D", "ALL"], flipped: false }, + { phase: "edges-back", title: "back edge wheels (U L R D ALL)", + names: ["U", "L", "R", "D", "ALL"], flipped: true }, + { phase: "corners", title: "corner wheels (UL UR DL DR)", + names: ["UL", "UR", "DL", "DR"], flipped: false }, + ]; + + const SOLVE_VECS = []; + for (const p of SOLVE_PLAN) { + for (const name of p.names) { + SOLVE_VECS.push(p.flipped ? swapVec(MOVE_VEC[name]) : MOVE_VEC[name].slice()); + } + } + + function solveAmounts(state) { + /* dial d equation: sum_i VEC[i][d] * t_i = -state[d] (mod 12) */ + const rows = []; + for (let d = 0; d < N; d++) { + rows.push(SOLVE_VECS.map((v) => ((v[d] % 12) + 12) % 12) + .concat([(((-state[d]) % 12) + 12) % 12])); + } + const width = SOLVE_VECS.length + 1; + const rowOf = new Array(SOLVE_VECS.length).fill(-1); + let row = 0; + const invOf = (p) => { + for (let q = 1; q < 12; q++) if ((p * q) % 12 === 1) return q; + return 0; /* unreachable for ±1 pivots */ + }; + for (let c = 0; c < width - 1 && row < rows.length; c++) { + let ri = -1; + for (let i = row; i < rows.length; i++) { + if (rows[i][c] % 12 !== 0) { ri = i; break; } + } + if (ri < 0) continue; + [rows[row], rows[ri]] = [rows[ri], rows[row]]; + const inv = invOf(((rows[row][c] % 12) + 12) % 12); + for (let j = 0; j < width; j++) rows[row][j] = (rows[row][j] * inv) % 12; + for (let i = 0; i < rows.length; i++) { + if (i === row || rows[i][c] % 12 === 0) continue; + const f = rows[i][c]; + for (let j = 0; j < width; j++) { + rows[i][j] = ((rows[i][j] - f * rows[row][j]) % 12 + 12) % 12; + } + } + rowOf[c] = row; + row++; + } + for (const r of rows) { + if (r.slice(0, width - 1).every((x) => ((x % 12) + 12) % 12 === 0) + && ((r[width - 1] % 12) + 12) % 12 !== 0) { + throw new Error("state is not in the reachable orbit: the pair laws are violated"); + } + } + const amounts = new Array(SOLVE_VECS.length).fill(0); + for (let c = 0; c < width - 1; c++) { + if (rowOf[c] >= 0) amounts[c] = ((rows[rowOf[c]][width - 1] % 12) + 12) % 12; + } + return amounts; + } + + /* amount -> token: 1..6 write "+", 7..11 write their complement with + "-" (UR8+ and UR4- are the same turn) */ + function amountToken(name, amount) { + const a = ((amount % 12) + 12) % 12; + if (a === 0) return null; + return a <= 6 ? name + a + "+" : name + (12 - a) + "-"; + } + + function solve(state) { + const verdict = validateState(state); + if (!verdict.ok) { + const bad = verdict.checks.filter((c) => !c.ok).map((c) => c.name).join(", "); + throw new Error("cannot solve: state fails validation (" + bad + ")"); + } + if (verifySolved(state).solved) { + return { moves: [], steps: [], final: state.slice(), wheelMoves: 0, + verified: verifySolved(state), legal: true }; + } + + const amounts = solveAmounts(state); + + /* build the token plan: skip zero amounts (no-ops); a flipped + section plays as y2, section, y2 */ + const plan = []; + let k = 0; + for (const p of SOLVE_PLAN) { + const toks = []; + for (const name of p.names) { + const tok = amountToken(name, amounts[k++]); + if (tok) toks.push({ tok, phase: p.phase, title: p.title }); + } + if (toks.length) { + if (p.flipped) plan.push({ tok: "y2", phase: p.phase + "-flip", title: "y2 flip" }); + plan.push(...toks); + if (p.flipped) plan.push({ tok: "y2", phase: p.phase + "-flip", title: "y2 flip back" }); + } + } + + /* replay-verify: every token through the public algebra, every + intermediate law-legal, the end all-zeros */ + let cur = state.slice(); + const steps = []; + const moves = []; + for (let i = 0; i < plan.length; i++) { + const item = plan[i]; + cur = applyMove(cur, item.tok); + const legal = validateState(cur).ok; + if (!legal) { + throw new Error("step " + i + " (" + item.tok + ") produced an unreachable state"); + } + moves.push(item.tok); + steps.push({ phase: item.phase, title: item.title, moves: [item.tok], + stateAfter: cur.slice() }); + } + const verified = verifySolved(cur); + if (!verified.solved) { + throw new Error("solve plan did not reach all-zeros (replay diverged from the algebra)"); + } + const wheelMoves = moves.filter((t) => t !== "y2").length; + return { moves, steps, final: cur, wheelMoves, verified, legal: true }; + } + + /* ---------- game ---------- */ + + function createGame(cfgRaw) { + const cfg = normalizeConfig(cfgRaw); + const scramble = makeScramble(cfg); + const state = applyAlg(SOLVED, scramble); + return { cfg, stamp: clockStamp(cfg), scramble, state, + solvedStart: verifySolved(state).solved }; + } + + globalThis.ClockCore = { + N, FRONT, BACK, SOLVED, MOVES, MOVE_VEC, REFERENCE, + FRONT_CORNERS, CORNER_PAIRS, AROUND, PINS_UP, PAIR_LAWS, SOLVE_PLAN, + backIndexOf, swapVec, swapHalves, + parseMove, applyMove, applyAlg, applyVec, + validateState, verifySolved, solve, solveAmounts, + DEFAULT_CONFIG, normalizeConfig, clockStamp, makeScramble, createGame, + }; +})(); diff --git a/site/clock.html b/site/clock.html new file mode 100644 index 0000000..c726bf4 --- /dev/null +++ b/site/clock.html @@ -0,0 +1,193 @@ + + + + + + + +OpenCodifier — Clock: solved by linear algebra, not luck + + + + + + + + + + + + + + + + + + + + + + + +
+

Rubik's Clock — solved by linear algebra, not luck

+

+ Nine dials a side, four pins, one flip. Turn a wheel and every dial it + couples to moves — the pin law baked into the move table is the WCA + scrambler's own. The scramble is WCA-shaped (five wheels a side, a + y2 between, amounts −5…+5 as TNoodle draws them). The + beautiful part: the clock is abelian and additive mod 12, + so every legal position has exactly one solution — the + coach computes it by Gauss-Jordan over Z₁₂ and replay-verifies every + wheel turn through the public move algebra. No ties exist to arbitrate, + so this page runs without the engine: the cheapest reliable mechanism + here is an exact rule, and pretending otherwise would be theater. +

+
+ +
+
+
+ +
+
+

What decides what

+
    +
  • The wheel table is the scrambler's own. Each of + the nine generators is derived from the pin law (front = union of + the around-an-up-pin dial sets, reverse side = the corner dials at + −1) and then checked, entry by entry, against the reference table + transcribed from TNoodle — the WCA scrambler of record. A mismatch + aborts at load.
  • +
  • The solution is unique — that is a theorem here. + The clock's group is abelian and additive mod 12; eliminating the + 18 generators leaves rank 14, exactly four independent constraints + (front+back ≡ 0 at each physical corner) — and 12¹⁴ reachable + positions, Jaap's published count. One legal state, one answer.
  • +
  • The coach is elimination, then replay. The plan + is the published method order (front edge wheels, y2, back edge + wheels, y2, corner wheels); the amounts are solved exactly in Z₁₂, + every applied token is re-checked against the move algebra, and a + divergence refuses with a named error instead of drifting.
  • +
  • No engine on this page — on purpose. There are no + genuine ties in a system with a unique solution. Escalating one to + a decision model would be a lie about what was decided.
  • +
+
+
+

Honest edges

+
    +
  • The four pins are shown in the standard solving posture (all up); + the wheel-dial coupling baked into the move table assumes it. Pin + manipulation as a solving step is not modeled.
  • +
  • Amounts are mod 12: UR8+ and UR4− are the same turn, and the move + log writes whichever form the plan or the scrambler produced.
  • +
  • The wheels act on the face that is toward you. The flip button is + a real y2 — the same move the scramble and the plan write.
  • +
  • The scoreboard is session-only: close the tab and it is gone. + Nothing leaves this page.
  • +
+
+
+
+
+ + + + + + + + + + diff --git a/site/clock.js b/site/clock.js new file mode 100644 index 0000000..ced8fec --- /dev/null +++ b/site/clock.js @@ -0,0 +1,549 @@ +/* Rubik's Clock shell — both faces rendered as dial grids, wheel buttons + with an amount stepper, a real y2 flip, and the coach from clock-core.js. + clock-core.js owns the 18-dial model, the WCA-shaped scramble, and the + solver (Gauss-Jordan over Z₁₂ on the 14 published-order generators, + replay-verified); this file is chrome. No engine on this page: the + clock's group is abelian, every legal state has exactly one solution, + and a system with no ties has nothing to arbitrate. */ +"use strict"; + +(function () { + const DK = globalThis.DecisionsSDK; + const CK = globalThis.ClockCore; + const root = document.getElementById("clock-root"); + if (!root || !DK || !CK) return; + + const h = DK.shell.h; + const byId = (id) => document.getElementById(id); + const nowMs = DK.nowMs; + + const WHEELS = CK.MOVES; /* ["UR","DR","DL","UL","U","R","D","L","ALL"] */ + /* dial grid order for display: reading rows — the core indexes the state + as [0..8] front row-major UL..DR, [9..17] back in the same reading */ + const DIAL_LABELS = ["UL", "UC", "UR", "CL", "CC", "CR", "DL", "DC", "DR"]; + + const S = { + cfg: { ...CK.DEFAULT_CONFIG }, + state: CK.SOLVED.slice(), /* a fresh clock is solved; scramble is the CTA */ + flipped: false, /* odd number of y2 applied: the back is toward you */ + history: [], + plan: null, + stepIdx: 0, + auto: false, autoTimer: 0, + wheel: "UR", /* selected wheel */ + amount: 3, + board: DK.sessionBoard(), + meters: DK.meters(), + }; + + /* ---------- dial rendering ---------- */ + /* one dial: a circle, a hand rotated value*30°, the value printed under + it. Two 3×3 grids: FRONT (state[0..8]) and BACK (state[9..17]). */ + const DIALS = []; /* { el, hand, val, side, pos } */ + const SIDE_TOWARD = "#e94b3c"; + const SIDE_AWAY = "#5a6472"; + + function buildSide(side) { + const grid = h("div", { + class: "clk-grid", role: "group", + "aria-label": side === "front" ? "front dials" : "back dials", + }); + grid.style.display = "grid"; + grid.style.gridTemplateColumns = "repeat(3, 64px)"; + grid.style.gap = "10px"; + for (let i = 0; i < 9; i++) { + const cell = h("div", { class: "clk-dial" }); + cell.style.width = "64px"; + cell.style.height = "64px"; + cell.style.borderRadius = "50%"; + cell.style.border = "2px solid " + (side === "front" ? SIDE_TOWARD : SIDE_AWAY); + cell.style.position = "relative"; + cell.style.background = "rgba(127,127,127,0.12)"; + const hand = h("div", { class: "clk-hand" }); + hand.style.position = "absolute"; + hand.style.left = "50%"; + hand.style.top = "50%"; + hand.style.width = "3px"; + hand.style.height = "24px"; + hand.style.background = "currentColor"; + hand.style.borderRadius = "2px"; + hand.style.transformOrigin = "50% 100%"; + hand.style.marginLeft = "-1.5px"; + hand.style.marginTop = "-24px"; + const val = h("span", { class: "clk-val" }); + val.style.position = "absolute"; + val.style.right = "3px"; + val.style.bottom = "1px"; + val.style.fontSize = "11px"; + val.style.fontWeight = "700"; + val.style.opacity = "0.85"; + cell.appendChild(hand); + cell.appendChild(val); + grid.appendChild(cell); + DIALS.push({ el: cell, hand, val, side, pos: i }); + } + return grid; + } + + function renderDials() { + for (const d of DIALS) { + const base = d.side === "front" ? 0 : 9; + const v = S.state[base + d.pos]; + d.hand.style.transform = "rotate(" + (v * 30) + "deg)"; + d.val.textContent = String(v); + const toward = (d.side === "front") !== S.flipped; + d.el.style.opacity = toward ? "1" : "0.55"; + d.el.title = DIAL_LABELS[d.pos] + " (" + d.side + ") = " + v + + (toward ? " — facing you" : ""); + } + } + + function render() { + renderDials(); + const solved = CK.verifySolved(S.state).solved; + const chip = byId("clk-solved-chip"); + if (chip) { + chip.textContent = solved ? "solved — all 12:00" : "scrambled"; + chip.classList.toggle("on", solved); + } + const laws = byId("clk-laws-chip"); + if (laws) { + const v = CK.validateState(S.state); + laws.textContent = v.ok ? "pair laws hold" : "laws: broken"; + laws.classList.toggle("on", v.ok); + laws.classList.toggle("off", !v.ok); + } + const side = byId("clk-side-chip"); + if (side) side.textContent = S.flipped ? "back toward you" : "front toward you"; + byId("clk-progress").textContent = S.plan + ? "step " + Math.min(S.stepIdx + 1, S.plan.steps.length) + "/" + S.plan.steps.length + : (S.history.length ? S.history.length + " turns from the scramble" : "ready"); + const solveBtn = byId("clk-solve-btn"); + const playBtn = byId("clk-play-btn"); + if (solveBtn) solveBtn.disabled = solved; + if (playBtn) playBtn.disabled = solved; + for (const b of root.querySelectorAll(".clk-wheel")) { + b.classList.toggle("on", b.getAttribute("data-wheel") === S.wheel); + } + renderMoveLog(); + } + + /* ---------- moves ---------- */ + function invertTok(tok) { + if (tok === "y2") return tok; + const m = /^(UR|DR|DL|UL|U|R|D|L|ALL)(\d+)([+-])$/.exec(tok); + if (!m) return tok; + const a = parseInt(m[2], 10); + if (a === 0) return tok; + return a <= 6 ? m[1] + a + (m[3] === "+" ? "-" : "+") + : m[1] + (12 - a) + (m[3] === "+" ? "+" : "-"); + } + + function applyTok(tok, record, fromPlan) { + S.state = CK.applyMove(S.state, tok); + if (tok === "y2") S.flipped = !S.flipped; + if (record !== false) S.history.push(tok); + if (!fromPlan && S.plan && S.stepIdx < S.plan.steps.length) { + S.plan = null; S.stepIdx = 0; + coach("your turn changed the clock — the plan below no longer fits. Solve again for a fresh one."); + } + } + function wheelTok(name, amount, dir) { + return name + amount + dir; + } + function userTurn() { + if (S.auto) stopAuto(); + applyTok(wheelTok(S.wheel, S.amount, "+")); + coach(S.wheel + " turned " + S.amount + " clockwise (viewed from the face toward you)."); + render(); + } + function userTurnBack() { + if (S.auto) stopAuto(); + applyTok(wheelTok(S.wheel, S.amount, "-")); + coach(S.wheel + " turned " + S.amount + " counter-clockwise."); + render(); + } + function userFlip() { + if (S.auto) stopAuto(); + applyTok("y2"); + coach("y2 — the other face is toward you. Wheels now act on it."); + render(); + } + function undo() { + if (S.auto) stopAuto(); + const tok = S.history.pop(); + if (!tok) return; + S.state = CK.applyMove(S.state, invertTok(tok)); + if (tok === "y2") S.flipped = !S.flipped; + S.plan = null; S.stepIdx = 0; + render(); + } + function coach(text) { byId("clk-coach").textContent = text; } + + /* ---------- solve + step-through ---------- */ + function solve() { + if (S.auto) stopAuto(); + if (CK.verifySolved(S.state).solved) { coach("already solved — scramble it first."); return; } + const t0 = nowMs(); + let plan = null; + try { + plan = CK.solve(S.state); + } catch (err) { + coach("the solver refused this position: " + err.message); + render(); + return; + } + S.meters.sample("solve.ms", nowMs() - t0); + S.plan = plan; + S.stepIdx = 0; + coach("plan ready: " + plan.wheelMoves + " wheel turns (+ y2 flips) in " + + plan.steps.length + " steps, solved by elimination over Z₁₂. " + + "Step through it, or let it play."); + buildStepList(); + render(); + } + function stepFwd() { + if (!S.plan || S.stepIdx >= S.plan.steps.length) return; + const st = S.plan.steps[S.stepIdx]; + applyTok(st.moves[0], undefined, true); + render(); + coach(st.title + " — " + st.moves[0]); + highlightStep(S.stepIdx); + S.stepIdx++; + if (S.stepIdx >= S.plan.steps.length) { + S.planSteps = S.plan.steps.length; + S.plan = null; S.stepIdx = 0; + recordBoard(); + coach("solved. " + S.history.length + " recorded turns from the scramble. Scramble for another round."); + } + render(); + } + function stepBack() { + if (S.auto) stopAuto(); + if (!S.plan || S.stepIdx === 0) return; + S.stepIdx--; + const st = S.plan.steps[S.stepIdx]; + S.state = CK.applyMove(S.state, invertTok(st.moves[0])); + if (st.moves[0] === "y2") S.flipped = !S.flipped; + coach("stepped back: " + st.title); + render(); + } + function startAuto() { + if (!S.plan) solve(); + if (!S.plan) return; + S.auto = true; + byId("clk-play-btn").textContent = "⏸ pause"; + tickAuto(); + } + function tickAuto() { + if (!S.auto) return; + if (!S.plan) { stopAuto(); return; } + stepFwd(); + if (S.auto) S.autoTimer = setTimeout(tickAuto, 550); + } + function stopAuto() { + S.auto = false; + clearTimeout(S.autoTimer); + const b = byId("clk-play-btn"); + if (b) b.textContent = "▶ auto-solve"; + } + function recordBoard() { + S.board.record({ + ts: Date.now(), outcome: "solved", + moves: S.history.length, + steps: S.planSteps || 0, + seed: S.cfg.seed, + stamp: CK.clockStamp(S.cfg), + }); + } + + /* ---------- step list ---------- */ + function buildStepList() { + const list = byId("clk-steplist"); + list.textContent = ""; + if (!S.plan) return; + S.plan.steps.forEach((st, i) => { + const row = h("div", { class: "cube-step", "data-step": String(i) }, + h("span", { class: "mz-chip game-rung" }, "rule"), + h("span", { class: "cube-step-title" }, st.title), + h("span", { class: "cube-step-moves" }, st.moves[0])); + row.addEventListener("click", () => jumpTo(i)); + list.appendChild(row); + }); + } + function highlightStep(i) { + for (const el of byId("clk-steplist").children) { + el.classList.toggle("now", el.getAttribute("data-step") === String(i)); + } + } + function jumpTo(i) { + if (!S.plan || S.auto) return; + while (S.stepIdx > i) stepBack(); + while (S.stepIdx < i) stepFwd(); + } + + /* newest on top; bounded DOM, count line keeps the truth */ + function renderMoveLog() { + const log = byId("clk-movelog"); + if (!log) return; + log.textContent = ""; + const hist = S.history; + if (!hist.length) { + log.appendChild(h("p", { class: "game-note" }, "no turns yet")); + return; + } + const windowN = 120; + const start = Math.max(0, hist.length - windowN); + for (let i = hist.length - 1; i >= start; i--) { + log.appendChild(h("span", + { class: "mz-chip cube-move" + (i === hist.length - 1 ? " latest" : "") }, hist[i])); + } + if (start > 0) { + log.appendChild(h("p", { class: "game-note" }, + "… " + start + " older turns (" + hist.length + " total)")); + } + } + + /* ---------- lifecycle ---------- */ + function newGame() { + stopAuto(); + const raw = byId("clk-seed").value.trim(); + const seed = raw === "" ? DK.randomSeed() : String(parseInt(raw, 10) || DK.randomSeed()); + byId("clk-seed").value = seed; + S.cfg = CK.normalizeConfig({ + seed, scrambleLen: +byId("clk-len").value, + }); + const g = CK.createGame(S.cfg); + S.state = g.state; + S.flipped = false; + S.history = []; + S.plan = null; S.stepIdx = 0; + coach("scrambled WCA-style: " + (S.cfg.scrambleLen) + " wheels a side around a y2 (seed " + + seed + "). Turn wheels by hand, or ask for the elimination plan."); + buildStepList(); + render(); + } + function resetParams() { + byId("clk-len").value = String(CK.DEFAULT_CONFIG.scrambleLen); + byId("clk-seed").value = ""; + newGame(); + } + + /* ---------- build ---------- */ + function build() { + const deepSeed = new URLSearchParams(window.location.search).get("seed"); + if (deepSeed) S.cfg.seed = String(parseInt(deepSeed, 10) || DK.randomSeed()); + + const wheelBtn = (name) => h("button", { + class: "mz-chip clk-wheel", type: "button", "data-wheel": name, + title: "select the " + name + " wheel", + }, name); + + const bar = h("div", { class: "game-bar" }, + h("button", { class: "mz-chip", id: "clk-scramble-btn", type: "button" }, "⟲ scramble"), + h("button", { class: "mz-chip", id: "clk-solve-btn", type: "button" }, "solve"), + h("button", { class: "mz-chip mz-play", id: "clk-play-btn", type: "button" }, "▶ auto-solve"), + h("button", { class: "mz-chip", id: "clk-back-btn", type: "button" }, "⏪"), + h("button", { class: "mz-chip", id: "clk-fwd-btn", type: "button" }, "⏩"), + h("button", { class: "mz-chip", id: "clk-undo-btn", type: "button" }, "↶ undo"), + h("button", { class: "mz-chip", id: "clk-flip-btn", type: "button" }, "⇅ flip (y2)"), + h("span", { class: "game-seed-wrap" }, + h("label", { for: "clk-seed", class: "game-slider-label" }, "seed "), + h("input", { type: "number", id: "clk-seed", class: "mz-input", size: "8", + placeholder: "random", min: "0", "aria-label": "numeric seed, empty for a random one each game" }), + h("button", { class: "mz-chip", id: "clk-dice", type: "button", + title: "roll a new random seed" }, "🎲")), + h("span", { class: "mz-chip game-rung", id: "clk-solved-chip" }, "scrambled"), + h("span", { class: "mz-chip game-rung", id: "clk-laws-chip" }, "pair laws hold"), + h("span", { class: "mz-chip game-rung", id: "clk-side-chip" }, "front toward you"), + h("button", { class: "mz-chip", id: "clk-reset-btn", type: "button", + title: "restore default parameters" }, "reset params"), + h("button", { class: "mz-chip", id: "clk-fs-btn", type: "button" }, "⛶ full screen"), + h("button", { class: "mz-chip", id: "clk-board-btn", type: "button" }, "★ scoreboard"), + h("button", { class: "mz-chip", id: "clk-stats-btn", type: "button" }, "ⓘ stats"), + ); + + const frontPane = h("div", { class: "clk-pane" }, + h("div", { class: "game-slider-label" }, "front"), + buildSide("front")); + const backPane = h("div", { class: "clk-pane" }, + h("div", { class: "game-slider-label" }, "back"), + buildSide("back")); + const stage = h("div", { id: "clk-stage", style: "display:flex;gap:28px;flex-wrap:wrap;justify-content:center;" }, + frontPane, backPane); + const under = h("div", { class: "mz-under game-under" }, + h("span", { class: "mz-kv" }, h("b", { id: "clk-progress" }, "ready"))); + const coachLine = h("p", { class: "game-hintline", id: "clk-coach", "aria-live": "polite" }, ""); + + const pins = h("div", { class: "clk-pins", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;" }, + ...["UL", "UR", "DL", "DR"].map((p) => h("span", { class: "mz-chip game-rung on" }, "pin " + p + ": up")), + h("span", { class: "game-note" }, + "standard solving posture — the move table's wheel-dial coupling assumes it")); + + const stepPane = h("div", null, + h("div", { class: "cube-steplist", id: "clk-steplist" }), + h("p", { class: "game-note" }, + "Steps appear once you press solve. Every step is a rule: the amounts ", + "are the unique solution of a linear system over Z₁₂ — there is no ", + "tie anywhere in a clock solve, so no engine, no roster, no random.", + "Click a step to jump to it.")); + + const opts = DK.shell.accordion([ + { + id: "turn", label: "Turn a wheel", open: true, + kids: [ + h("div", { class: "cube-moves", id: "clk-wheels" }, + ...WHEELS.map(wheelBtn)), + h("label", { class: "game-slider" }, + h("span", { class: "game-slider-label" }, "turn amount (hours, 1–6)"), + h("input", { type: "range", id: "clk-amount", min: "1", max: "6", + step: "1", value: String(S.amount), "aria-label": "turn amount in hours" }), + h("span", { class: "game-sliderval", id: "clk-amount-val" }, String(S.amount))), + h("div", { class: "cube-moves" }, + h("button", { class: "mz-chip mz-play", id: "clk-cw-btn", type: "button" }, "↻ turn CW"), + h("button", { class: "mz-chip", id: "clk-ccw-btn", type: "button" }, "↺ turn CCW")), + h("p", { class: "game-note" }, + "Select a wheel, pick an amount, turn. Wheels act on the face ", + "toward you — flip with y2 to reach the other side. A 6-hour turn ", + "is the half-way mark; the core writes amounts past it as their ", + "complement (UR8+ and UR4- are the same turn)."), + pins, + ], + }, + { + id: "setup", label: "Scramble", + kids: [ + h("label", { class: "game-slider" }, + h("span", { class: "game-slider-label" }, "wheels per side (WCA uses 5)"), + h("input", { type: "range", id: "clk-len", min: "1", max: "9", + step: "1", value: String(S.cfg.scrambleLen), "aria-label": "wheels per side" }), + h("span", { class: "game-sliderval", id: "clk-len-val" }, String(S.cfg.scrambleLen))), + h("p", { class: "game-note" }, + "The scramble follows the WCA shape: distinct wheel moves in ", + "canonical order, a y2, then the same for the back side, amounts ", + "−5…+5 drawn the way the official scrambler draws them. The seed ", + "is optional: leave it empty and every game rolls a fresh random ", + "number, shown so the run stays reproducible."), + ], + }, + { + id: "about", label: "Why no engine here?", + kids: [h("p", { class: "game-note" }, + "Because there is nothing to decide. The clock's move group is ", + "abelian: wheels commute, amounts add mod 12, and eliminating the ", + "generators leaves rank 14 with exactly four consistency laws — so ", + "every legal position has ONE solution. A tie is a decision with ", + "several defensible answers; a clock solve has none. This is the ", + "runtime's first principle (cheapest reliable mechanism first) ", + "applied to itself: an exact rule beats an escalation, and an ", + "escalation here would be theater. The engine pages — maze, pong, ", + "the cube — have genuine ties; this page has a theorem.")], + }, + ]); + + const moveLog = h("div", null, + h("div", { class: "game-slider-label" }, "move history — newest first"), + h("div", { class: "cube-movelog", id: "clk-movelog", + "aria-live": "polite", "aria-label": "move history" }, + h("p", { class: "game-note" }, "no turns yet"))); + + const main = h("div", { class: "game-layout" }, + h("div", { class: "game-center" }, stage, under, coachLine), + h("div", { class: "game-side" }, opts, moveLog, stepPane)); + root.appendChild(h("div", { class: "game-wrap", id: "clk-wrap" }, bar, main)); + + buildOverlays(); + wire(); + newGame(); /* boots paused — solve/auto-solve is the play button */ + } + + /* ---------- overlays ---------- */ + function overlay(id, title, bodyId, closeId) { + return h("dialog", { class: "mz-overlay", id }, + h("div", { class: "mz-board-card" }, + h("div", { class: "mz-overlay-head" }, + h("span", { class: "mz-overlay-title" }, title), + h("button", { class: "mz-chip", id: closeId, type: "button" }, "✕ close")), + h("div", { class: "mz-overlay-scroll", id: bodyId }))); + } + function buildOverlays() { + root.appendChild(overlay("clk-board-overlay", "Session scoreboard", "clk-board-body", "clk-board-close")); + root.appendChild(overlay("clk-stats-overlay", "Measured stats", "clk-stats-body", "clk-stats-close")); + } + function renderBoard() { + const body = byId("clk-board-body"); + if (!body) return; + body.textContent = ""; + const rows = S.board.all(); + if (!rows.length) { + body.appendChild(h("p", { class: "game-note" }, + "No solves yet — finish a plan (step through or auto-solve) and the ", + "run lands here. Session only.")); + return; + } + body.appendChild(h("table", { class: "game-table" }, + h("thead", null, h("tr", null, + ...["#", "turns", "seed", "when"].map((x) => h("th", null, x)))), + h("tbody", null, rows.map((r, i) => h("tr", null, + h("td", null, String(rows.length - i)), + h("td", null, String(r.moves)), + h("td", null, r.seed), + h("td", null, new Date(r.ts).toLocaleTimeString())))))); + body.appendChild(h("button", { + class: "mz-chip", type: "button", onclick: () => { S.board.clear(); renderBoard(); }, + }, "clear board")); + } + function renderStats() { + const body = byId("clk-stats-body"); + if (!body) return; + body.textContent = ""; + const m = S.meters.snapshot(); + const kv = (label, val) => h("p", { class: "game-note" }, label + " " + val); + body.appendChild(kv("solve compute avg:", (m["solve.ms"] ? m["solve.ms"].avg.toFixed(1) : "0") + " ms")); + body.appendChild(kv("history depth:", String(S.history.length))); + body.appendChild(kv("flipped:", S.flipped ? "yes (back toward you)" : "no")); + } + + /* ---------- wire ---------- */ + function wire() { + byId("clk-scramble-btn").addEventListener("click", newGame); + byId("clk-solve-btn").addEventListener("click", solve); + byId("clk-play-btn").addEventListener("click", () => (S.auto ? stopAuto() : startAuto())); + byId("clk-fwd-btn").addEventListener("click", () => { if (S.auto) stopAuto(); stepFwd(); }); + byId("clk-back-btn").addEventListener("click", stepBack); + byId("clk-undo-btn").addEventListener("click", undo); + byId("clk-flip-btn").addEventListener("click", userFlip); + byId("clk-cw-btn").addEventListener("click", userTurn); + byId("clk-ccw-btn").addEventListener("click", userTurnBack); + byId("clk-wheels").addEventListener("click", (e) => { + const btn = e.target.closest("button.clk-wheel"); + if (!btn) return; + S.wheel = btn.getAttribute("data-wheel"); + render(); + }); + byId("clk-amount").addEventListener("input", () => { + S.amount = parseInt(byId("clk-amount").value, 10) || 1; + byId("clk-amount-val").textContent = String(S.amount); + }); + byId("clk-dice").addEventListener("click", () => { + byId("clk-seed").value = DK.randomSeed(); + newGame(); + }); + byId("clk-seed").addEventListener("change", newGame); + byId("clk-len").addEventListener("input", () => { + byId("clk-len-val").textContent = byId("clk-len").value; + }); + byId("clk-len").addEventListener("change", newGame); + byId("clk-reset-btn").addEventListener("click", resetParams); + byId("clk-board-btn").addEventListener("click", () => { renderBoard(); byId("clk-board-overlay").showModal(); }); + byId("clk-board-close").addEventListener("click", () => byId("clk-board-overlay").close()); + byId("clk-stats-btn").addEventListener("click", () => { renderStats(); byId("clk-stats-overlay").showModal(); }); + byId("clk-stats-close").addEventListener("click", () => byId("clk-stats-overlay").close()); + DK.shell.fullscreen(byId("clk-wrap"), byId("clk-fs-btn")); + + /* first paint: the fresh clock IS solved — say so (scramble is the CTA) */ + render(); + } + + build(); +})(); diff --git a/site/cube-n-core.js b/site/cube-n-core.js new file mode 100644 index 0000000..088e073 --- /dev/null +++ b/site/cube-n-core.js @@ -0,0 +1,577 @@ +/* N×N×N cube family — core. + The generalized sibling of cube-core.js: one parametric facelet model for + 2×2×2 through 21×21×21. Everything is DERIVED from geometry, never + hand-transcribed: cubie coordinates on the (n-1)-parity lattice, sticker + facelets from the standard URFDLB numbering generalized to n, and move + permutations computed by rotating cubies and their sticker normals with + the same matrix (SiGN notation: U, Uw, 3Uw, … covers every layer depth). + + The validator pins exactly the laws that simulation PROVED hold for this + family — and no more: + - color counts (n² per face) — every n; + - piece multiplicity (8 corners once each; every edge color-pair exactly + n-2 times) — every n; + - corner-twist sum ≡ 0 mod 3, chirality from the slot-normal determinant + — verified by seeded random walks on n = 3, 4, 5, 7; + - edge-flip sum ≡ 0 mod 2, Kociemba's canonical-first rule (the slot's + U/D facelet else F/B facelet vs the cubie's U/D color else F/B color) + — verified on n = 3…8. A 2×2 has no edge pieces, so the flip law is + vacuous there by construction, not by omission. + Permutation parity is deliberately NOT asserted: simulation shows + corner/edge parity decouples for n ≥ 4 (the 4×4 PLL-parity family) and + a 2×2 has no edges to couple with — odd corner permutations are legal + there. A validator that asserts a law the moves can break would reject + legal states; refusing a legal state is the one failure this file + never makes. + + Solver coverage is honest and small: 2×2 only (D layer → top orientation + → top permutation, every candidate step simulated BEFORE it is played, + ties broken by fixed roster order unless the shell passes a chooser). + 3×3 stays on the battle-tested cube-core.js; n ≥ 4 is free play — + algebra, scramble, and validator, with the solve button told to say no. + + The shell (cube.js) renders and wires; this file never touches the DOM. */ + +(function () { + "use strict"; + + const MIN_N = 2; + const MAX_N = 21; + const FACES = ["U", "R", "F", "D", "L", "B"]; + const NORMAL = { + U: [0, 1, 0], R: [1, 0, 0], F: [0, 0, 1], + D: [0, -1, 0], L: [-1, 0, 0], B: [0, 0, -1], + }; + + /* default scramble length per size: long enough to be interesting, + short enough that the move log stays readable */ + function defaultScrambleLen(n) { + if (n === 2) return 12; + if (n <= 7) return 25; + return 30; + } + + /* mulberry32 — seeded, deterministic, no Math.random anywhere */ + function rng32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + function createCore(n) { + if (!Number.isInteger(n) || n < MIN_N || n > MAX_N) { + throw new Error("cube size must be an integer " + MIN_N + ".." + MAX_N + ", got " + n); + } + const m = n - 1; + const faceCount = n * n; + const stateSize = 6 * faceCount; + const maxDepth = Math.floor(n / 2); + + /* ---------- geometry ---------- */ + + /* cubie coordinates live on the (n-1)-parity lattice: ±(n-1), ±(n-3), … + A cubie is a surface cubie iff any |coord| = n-1; a sticker of face f + exists iff the f-normal dotted with the position is n-1. */ + const CUBIE_COORDS = []; + for (let v = -(n - 1); v <= n - 1; v += 2) { + /* single-axis walk reused below; the full product is built after */ + } + const AXIS = []; + for (let v = -(n - 1); v <= n - 1; v += 2) AXIS.push(v); + for (const x of AXIS) for (const y of AXIS) for (const z of AXIS) { + if (Math.abs(x) === m || Math.abs(y) === m || Math.abs(z) === m) CUBIE_COORDS.push([x, y, z]); + } + + /* facelet index for the sticker of face f on the cubie at (x,y,z); + the n=3 case is identical to cube-core's URFDLB numbering */ + function faceletAt(face, x, y, z) { + const b = FACES.indexOf(face) * faceCount; + switch (face) { + case "U": return b + (z + m) / 2 * n + (x + m) / 2; + case "R": return b + (m - y) / 2 * n + (m - z) / 2; + case "F": return b + (m - y) / 2 * n + (x + m) / 2; + case "D": return b + (m - z) / 2 * n + (x + m) / 2; + case "L": return b + (m - y) / 2 * n + (z + m) / 2; + case "B": return b + (m - y) / 2 * n + (m - x) / 2; + default: throw new Error("unknown face " + face); + } + } + const faceOf = (idx) => FACES[Math.floor(idx / faceCount)]; + + /* rotate (x,y,z) for a clockwise turn of `face` viewed from that face — + the same table cube-core uses */ + function rotateFor(face, p) { + const [x, y, z] = p; + switch (face) { + case "U": return [-z, y, x]; + case "D": return [z, y, -x]; + case "R": return [x, z, -y]; + case "L": return [x, -z, y]; + case "F": return [y, -x, z]; + case "B": return [-y, x, z]; + default: throw new Error("unknown face " + face); + } + } + + /* quarter-turn permutation for the outermost `layers` layers at once + (a SiGN "kw" turn; layers = 1 is the plain face turn). One + geometric pass: rotate each moved cubie and each sticker normal with + the same matrix. The perm maps new facelet index -> old facelet + index; disjoint layers make the single pass the exact composition. */ + function turnPerm(face, layers) { + const perm = new Array(stateSize); + for (let i = 0; i < stateSize; i++) perm[i] = i; + const axis = NORMAL[face]; + const cs = []; + for (let d = 0; d < layers; d++) cs.push(m - 2 * d); + for (const [x, y, z] of CUBIE_COORDS) { + if (!cs.includes(axis[0] * x + axis[1] * y + axis[2] * z)) continue; + const np = rotateFor(face, [x, y, z]); + for (const f of FACES) { + const nv = NORMAL[f]; + if (nv[0] * x + nv[1] * y + nv[2] * z !== m) continue; + const nr = rotateFor(face, nv); + const nf = FACES.find((g) => { + const q = NORMAL[g]; + return q[0] === nr[0] && q[1] === nr[1] && q[2] === nr[2]; + }); + perm[faceletAt(nf, np[0], np[1], np[2])] = faceletAt(f, x, y, z); + } + } + return perm; + } + + /* ---------- move table (SiGN notation) ---------- */ + + function baseToken(face, layers) { + if (layers === 1) return face; + if (layers === 2) return face + "w"; + return String(layers) + face + "w"; + } + const MOVES = {}; /* base token -> quarter-turn perm */ + const MOVE_KEYS = []; + for (const f of FACES) { + for (let layers = 1; layers <= maxDepth; layers++) { + const tok = baseToken(f, layers); + MOVES[tok] = turnPerm(f, layers); + MOVE_KEYS.push(tok); + } + } + + /* SiGN tokens: number-first for wide bands ("3Uw"), so the count comes + BEFORE the face; a bare "w" means 2 layers */ + const MOVE_RE = /^(\d+)?([URFDLB])(w)?(2|')?$/; + function parseMove(tok) { + const mt = MOVE_RE.exec(tok); + if (!mt) return null; + const face = mt[2]; + const layers = mt[1] ? parseInt(mt[1], 10) : (mt[3] ? 2 : 1); + if (layers < 1 || layers > maxDepth) return null; + const quarters = mt[4] === "2" ? 2 : mt[4] === "'" ? 3 : 1; + return { face, layers, quarters, base: baseToken(face, layers) }; + } + const applyPerm = (state, perm) => state.map((_, i) => state[perm[i]]); + function applyMove(state, tok) { + const mv = parseMove(tok); + if (!mv) throw new Error("unknown move " + tok); + let out = state; + for (let q = 0; q < mv.quarters; q++) out = applyPerm(out, MOVES[mv.base]); + return out; + } + function applyAlg(state, toks) { + let out = state; + for (const t of toks) out = applyMove(out, t); + return out; + } + function invertToken(tok) { + if (tok.endsWith("2")) return tok; + return tok.endsWith("'") ? tok.slice(0, -1) : tok + "'"; + } + const invertAlg = (toks) => toks.slice().reverse().map(invertToken); + + /* ---------- slots: corners and edge positions ---------- + slot ids are FACES-order joins (URF, RFD, DLB, …): U always leads a + U-corner id, so id[0] === "U" is the U-layer test. */ + const CORNER_SLOTS = []; + const EDGE_SLOTS = []; + for (const [x, y, z] of CUBIE_COORDS) { + const mags = [Math.abs(x), Math.abs(y), Math.abs(z)].sort((a, b) => b - a); + const st = []; + for (const f of FACES) { + const nv = NORMAL[f]; + if (nv[0] * x + nv[1] * y + nv[2] * z === m) st.push(faceletAt(f, x, y, z)); + } + if (mags[0] === m && mags[1] === m && mags[2] === m) { + CORNER_SLOTS.push({ id: st.map(faceOf).join(""), f: st }); + } else if (mags[0] === m && mags[1] === m) { + /* canonical facelet: the U/D face, else the F/B face (cube-core's + canonical-first rule; the fallback must pick by FACE CLASS, not + slot order — the flip law walk caught exactly this) */ + let canon = st.findIndex((i) => { const fc = faceOf(i); return fc === "U" || fc === "D"; }); + if (canon < 0) canon = st.findIndex((i) => { const fc = faceOf(i); return fc === "F" || fc === "B"; }); + EDGE_SLOTS.push({ f: st, canon }); + } + } + + /* ---------- validator ---------- */ + + const solvedState = []; + for (let f = 0; f < 6; f++) for (let i = 0; i < faceCount; i++) solvedState.push(FACES[f]); + + /* corner chirality: sign of the determinant of the slot's three outward + normals, ordered by FACES index. Simulation-derived law (twist probe, + n = 3, 4, 5, 7): twist = chi*p - home, sum ≡ 0 mod 3. */ + const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const CHI = CORNER_SLOTS.map((slot) => { + const nrm = slot.f.map((i) => NORMAL[faceOf(i)]); + return Math.sign(dot(cross(nrm[0], nrm[1]), nrm[2])) || 1; + }); + const HOME_TWIST = CORNER_SLOTS.map((slot, s) => { + const cols = slot.f.map((i) => solvedState[i]); + const p = cols.findIndex((c) => c === "U" || c === "D"); + return ((CHI[s] * p) % 3 + 3) % 3; + }); + const HOME_CORNER_KEYS = CORNER_SLOTS.map((s) => s.f.map((i) => solvedState[i]).sort().join("")); + + function validateState(state) { + const issues = []; + const add = (check, ok, detail) => { if (!ok) issues.push({ check, detail }); }; + + if (!Array.isArray(state) || state.length !== stateSize) { + return { legal: false, issues: [{ check: "shape", detail: "expected " + stateSize + " facelets, got " + (state ? state.length : "null") }] }; + } + const counts = {}; + for (const c of state) counts[c] = (counts[c] || 0) + 1; + for (const f of FACES) { + add("color-count", counts[f] === faceCount, + "color " + f + " appears " + (counts[f] || 0) + " times, expected " + faceCount); + } + /* piece multiplicity: each corner triple exactly once, each edge + color-pair exactly n-2 times (0 pairs on a 2×2 — vacuous) */ + const cornerSeen = {}; + const edgePairSeen = {}; + for (const slot of CORNER_SLOTS) { + const key = slot.f.map((i) => state[i]).sort().join(""); + cornerSeen[key] = (cornerSeen[key] || 0) + 1; + } + for (const slot of EDGE_SLOTS) { + const key = slot.f.map((i) => state[i]).sort().join(""); + edgePairSeen[key] = (edgePairSeen[key] || 0) + 1; + } + for (const key of HOME_CORNER_KEYS) { + add("piece-multiplicity", cornerSeen[key] === 1, + "corner " + key + " appears " + (cornerSeen[key] || 0) + " times, expected 1"); + } + const wantEdge = EDGE_SLOTS.length / 12; + for (const key of Object.keys(edgePairSeen)) { + add("piece-multiplicity", edgePairSeen[key] === wantEdge, + "edge pair " + key + " appears " + edgePairSeen[key] + " times, expected " + wantEdge); + } + /* corner-twist sum ≡ 0 mod 3 */ + let twistSum = 0; + CORNER_SLOTS.forEach((slot, s) => { + const cols = slot.f.map((i) => state[i]); + const p = cols.findIndex((c) => c === "U" || c === "D"); + const raw = ((CHI[s] * p) % 3 + 3) % 3; + twistSum += ((raw - HOME_TWIST[s]) % 3 + 3) % 3; + }); + add("corner-twist", twistSum % 3 === 0, + "corner twists sum to " + (twistSum % 3) + " mod 3 — legal cubes always sum to 0"); + /* edge-flip sum ≡ 0 mod 2 (no edge slots on a 2×2 — vacuous there) */ + if (EDGE_SLOTS.length) { + let flipSum = 0; + for (const slot of EDGE_SLOTS) { + const cols = slot.f.map((i) => state[i]); + const want = cols.find((c) => c === "U" || c === "D") + || cols.find((c) => c === "F" || c === "B"); + if (state[slot.f[slot.canon]] !== want) flipSum++; + } + add("edge-flip", flipSum % 2 === 0, + "edge flips sum to " + (flipSum % 2) + " mod 2 — legal cubes always sum to 0"); + } + return { legal: issues.length === 0, issues }; + } + + function verifySolved(state) { + return { solved: state.every((c, i) => c === solvedState[i]) }; + } + + /* ---------- scrambles ---------- */ + + function makeScramble(seed, len) { + const count = len || defaultScrambleLen(n); + const rand = rng32(seed); + const toks = []; + let lastFace = ""; + for (let i = 0; i < count; i++) { + let f = FACES[Math.floor(rand() * 6)]; + while (f === lastFace) f = FACES[Math.floor(rand() * 6)]; + lastFace = f; + const layers = 1 + Math.floor(rand() * maxDepth); + const v = rand(); + const suffix = v < 0.4 ? "" : v < 0.7 ? "'" : "2"; + toks.push(baseToken(f, layers) + suffix); + } + return toks; + } + + function createGame(cfg) { + const seed = String(cfg.seed); + const len = cfg.scrambleLen || defaultScrambleLen(n); + /* FNV-1a of the seed so string seeds scramble deterministically */ + let hsh = 2166136261; + for (let i = 0; i < seed.length; i++) { + hsh ^= seed.charCodeAt(i); + hsh = Math.imul(hsh, 16777619); + } + const scramble = makeScramble(hsh >>> 0, len); + let state = solvedState.slice(); + for (const t of scramble) state = applyMove(state, t); + return { state, scramble, seed }; + } + + const core = { + n, m, maxDepth, FACES, NORMAL, faceCount, stateSize, solvedState, + CUBIE_COORDS, MOVES, MOVE_KEYS, + faceletAt, faceOf, applyMove, applyAlg, invertToken, invertAlg, parseMove, + baseToken, validateState, verifySolved, makeScramble, createGame, + defaultScrambleLen, + cornerSlots: CORNER_SLOTS, edgeSlots: EDGE_SLOTS, + solverAvailable: n === 2, + }; + if (n === 2) core.solve = (state, chooser) => solve2(core, state, chooser); + return core; + } + + /* ---------- 2×2 solver (the only size this file coaches) ---------- */ + + /* insert algs per D corner slot (slot ids in FACES order: RFD = the + front-right D corner). Each list covers one sticker orientation of the + cubie sitting in the U layer above the slot. */ + const INSERTS = { + RFD: [["R", "U", "R'"], ["F'", "U'", "F"], ["R", "U2", "R'", "U'", "R", "U", "R'"]], + RDB: [["B", "U", "B'"], ["R'", "U'", "R"], ["B", "U2", "B'", "U'", "B", "U", "B'"]], + DLB: [["L", "U", "L'"], ["B'", "U'", "B"], ["L", "U2", "L'", "U'", "L", "U", "L'"]], + FDL: [["F", "U", "F'"], ["L'", "U'", "L"], ["F", "U2", "F'", "U'", "F", "U", "F'"]], + }; + /* one insert-run that lifts a cubie out of its D slot */ + const POP = { + RFD: ["R", "U", "R'"], RDB: ["B", "U", "B'"], DLB: ["L", "U", "L'"], FDL: ["F", "U", "F'"], + }; + const SEXY = ["R", "U", "R'", "U'"]; /* insert rhythm, not the orienter */ + /* the classic corner orienter: repeat until the URF cubie is oriented + (2 or 4 reps), U between corners; the bottom looks scrambled mid-loop + and restores when the last corner is done. Simulation chose this over + the (R U R' U') variant 150-0 — that one leaves the bottom broken. */ + const RDRD = ["R'", "D'", "R", "D"]; + /* Niklas: pure corner 3-cycle (even). T-perm: adjacent corner swap (odd) + — on a 2×2 there are no edges, so T-perm is exactly the odd-permutation + fixer the Niklas family cannot reach. */ + const NIKLAS = ["R", "U'", "L'", "U", "R'", "U'", "L", "U"]; + const TPERM = ["R", "U", "R'", "U'", "R'", "F", "R2", + "U'", "R'", "U'", "R", "U", "R'", "F'"]; + const U_PRE = [[], ["U"], ["U2"], ["U'"]]; + + /* exhaustive phase-3 search: BFS over the 24 permutations of the U + corners. Every expansion replays through the public move algebra, so a + found plan is verified by construction; the chooser breaks ties among + equally short plans. Returns { toks, detail } or null. */ + function bfsPermute(core, start, gens, pickIdx) { + if (core.verifySolved(start).solved) return { toks: [], detail: "already permuted" }; + const key = (st) => st.join(""); + let frontier = [{ st: start, path: [] }]; + const seen = new Set([key(start)]); + for (let depth = 1; depth <= 6; depth++) { + const next = []; + const solutions = []; + for (const node of frontier) { + for (const g of gens) { + const st = core.applyAlg(node.st, g.toks); + const k = key(st); + if (seen.has(k)) continue; + seen.add(k); + const path = [...node.path, g]; + if (core.verifySolved(st).solved) solutions.push(path); + else next.push({ st, path }); + } + } + if (solutions.length) { + const idx = pickIdx(solutions.map((p) => p.map((g) => g.label).join(", then ")), "pll"); + const path = solutions[Number.isInteger(idx) && idx >= 0 && idx < solutions.length ? idx : 0]; + return { + toks: path.flatMap((g) => g.toks), + detail: path.length + " PLL" + (path.length > 1 ? "s" : "") + ": " + + path.map((g) => g.label).join(", then ") + + " — shortest of " + solutions.length + " equal-length plans", + }; + } + if (!next.length) return null; + frontier = next; + } + return null; + } + + function solve2(core, state, chooser) { + if (core.n !== 2) return null; + const v = core.validateState(state); + if (!v.legal) return { refused: true, reason: "invalid state: " + v.issues[0].check, steps: [], moves: [] }; + + const D_SLOTS = ["RFD", "RDB", "DLB", "FDL"]; + const steps = []; + const moves = []; + let cur = state.slice(); + const done = new Set(); /* D slots already solved and protected */ + + const slotOf = (id) => core.cornerSlots.find((s) => s.id === id); + const homeKey = (id) => slotOf(id).f.map((i) => core.solvedState[i]).sort().join(""); + const slotDone = (st, id) => slotOf(id).f.every((i) => st[i] === core.solvedState[i]); + const dLayerDone = (st) => D_SLOTS.every((id) => slotDone(st, id)); + const cubieInU = (st, key) => { + const hit = core.cornerSlots.find((s) => s.id[0] === "U" + && s.f.map((i) => st[i]).sort().join("") === key); + return hit ? hit.id : null; + }; + const ufrOriented = (st) => { + const slot = slotOf("URF"); + const cols = slot.f.map((i) => st[i]); + const uIdx = cols.findIndex((c) => c === "U"); + const canon = slot.f.findIndex((i) => core.faceOf(i) === "U"); + return uIdx === canon; + }; + const uFaceOriented = (st) => ["URF", "ULB", "URB", "UFL"].every((id) => { + const slot = slotOf(id); + const cols = slot.f.map((i) => st[i]); + const uIdx = cols.findIndex((c) => c === "U"); + const canon = slot.f.findIndex((i) => core.faceOf(i) === "U"); + return uIdx === canon; + }); + const misplaced = (st) => core.cornerSlots.filter((s) => !s.f.every((i) => st[i] === core.solvedState[i])).length; + const play = (toks, title, detail) => { + cur = core.applyAlg(cur, toks); + moves.push(...toks); + steps.push({ title, moves: toks.slice(), detail: detail || "" }); + }; + const pickIdx = (cand, tag) => { + if (!chooser) return 0; + const c = chooser(cand.map((x) => x.detail), tag); + return Number.isInteger(c) && c >= 0 && c < cand.length ? c : 0; + }; + + /* --- phase 1: D layer, slot by slot; every insert verified to land the + target cubie WITHOUT disturbing already-solved slots --- */ + for (const slotId of D_SLOTS) { + if (slotDone(cur, slotId)) { done.add(slotId); continue; } + const key = homeKey(slotId); + if (slotOf(slotId).f.map((i) => cur[i]).sort().join("") === key) { + /* the right cubie, twisted in place: pop it out and re-insert */ + play(POP[slotId], "free " + slotId, "the cubie belongs here but is twisted — pop it to the top first"); + } else if (!cubieInU(cur, key)) { + /* it sits in another D slot: pop that one */ + const other = D_SLOTS.find((id) => id !== slotId + && slotOf(id).f.map((i) => cur[i]).sort().join("") === key); + if (!other) return { refused: true, reason: "cubie for " + slotId + " not found", steps, moves }; + play(POP[other], "free " + other, "the cubie needed for " + slotId + " sits in " + other); + } + if (!cubieInU(cur, key)) { + return { refused: true, reason: "cubie for " + slotId + " not in the top layer", steps, moves }; + } + const cand = []; + for (const pre of U_PRE) { + for (const ins of INSERTS[slotId]) { + const toks = [...pre, ...ins]; + const after = core.applyAlg(cur, toks); + if (slotDone(after, slotId) && [...done].every((id) => slotDone(after, id))) { + cand.push({ + toks, + detail: (pre.length ? pre.join(" ") + ", then " : "") + ins.join(" "), + }); + break; /* one insert per alignment */ + } + } + } + if (!cand.length) return { refused: true, reason: "no verified insert for " + slotId, steps, moves }; + const pick = cand[pickIdx(cand, "insert-" + slotId)]; + play(pick.toks, "insert " + slotId, pick.detail); + done.add(slotId); + } + + /* --- phase 2: orient the top. The (R U R' U')×k loop twists each top + corner in turn (one U between corners); the bottom layer looks broken + mid-loop and restores when the last corner is done. The whole loop is + ONE step, verified in simulation before it is played. --- */ + if (!uFaceOriented(cur)) { + const toks = []; + let sim = cur; + for (let c = 0; c < 4; c++) { + if (c > 0) { toks.push("U"); sim = core.applyMove(sim, "U"); } + /* orient the cubie CURRENTLY at URF: only an even number of sexy + reps brings the same cubie back — odd counts leave a different + cubie in the slot with the bottom layer broken. Its twist cycles + through all three states over 0/2/4 reps, so exactly one works. */ + const urfKey = slotOf("URF").f.map((i) => sim[i]).sort().join(""); + let done = false; + for (const reps of [0, 2, 4]) { + let t2 = sim; + const seq = []; + for (let r = 0; r < reps; r++) { + for (const t of RDRD) { seq.push(t); t2 = core.applyMove(t2, t); } + } + const key2 = slotOf("URF").f.map((i) => t2[i]).sort().join(""); + if (key2 === urfKey && ufrOriented(t2)) { + for (const t of seq) toks.push(t); + sim = t2; + done = true; + break; + } + } + if (!done) return { refused: true, reason: "orientation loop stalled at URF", steps, moves }; + } + if (!uFaceOriented(sim) || !dLayerDone(sim)) { + return { refused: true, reason: "orientation loop did not restore the bottom", steps, moves }; + } + cur = sim; + steps.push({ + title: "orient the top", + moves: toks.slice(), + detail: "the (R' D' R D)×k loop twists each top corner in turn — the bottom looks scrambled mid-loop and restores at the end", + }); + moves.push(...toks); + } + + /* --- phase 3: permute the top. With the D layer done and every + corner oriented, the U corners carry one of the 24 permutations of + S4. Generators: Niklas 3-cycles and their inverses (even) at the + four U alignments, plus the T-perm adjacent swap (odd) — legal on a + 2×2 precisely because there are no edges. A BFS over the whole + (finite) permutation space finds a shortest generator sequence; + a greedy descent cannot: a diagonal transposition is unsolvable by + any single adjacent-swap alignment, so greedy loops forever. --- */ + const NIKLAS_INV = core.invertAlg(NIKLAS); + const GENS = []; + for (const pre of U_PRE) { + const align = pre.length ? " after " + pre.join(" ") : " in place"; + GENS.push({ toks: [...pre, ...NIKLAS], label: "Niklas 3-cycle" + align }); + GENS.push({ toks: [...pre, ...NIKLAS_INV], label: "Niklas inverse" + align }); + GENS.push({ toks: [...pre, ...TPERM], label: "T-swap" + align }); + } + const plan3 = bfsPermute(core, cur, GENS, pickIdx); + if (!plan3) return { refused: true, reason: "permutation did not converge", steps, moves }; + if (plan3.toks.length) play(plan3.toks, "permute the top", plan3.detail); + if (!core.verifySolved(cur).solved) { + return { refused: true, reason: "permutation did not converge", steps, moves }; + } + + return { refused: false, moves, steps }; + } + + globalThis.CubeNCore = { + MIN_N, MAX_N, FACES, NORMAL, + createCore, + defaultScrambleLen, + }; +})(); diff --git a/site/cube.html b/site/cube.html index 53ec1b1..61be80f 100644 --- a/site/cube.html +++ b/site/cube.html @@ -19,16 +19,16 @@ document.documentElement.setAttribute("data-theme-mode", mode); })(); -OpenCodifier — Rubik's Cube: a solver that shows its decisions - +OpenCodifier — Twisty Cubes 2×2–21×21: a solver that shows its decisions + - - + + @@ -37,7 +37,7 @@ - + @@ -83,56 +83,73 @@
-

Rubik's Cube — a solver that shows its decisions

+

Twisty cubes, 2×2 to 21×21 — a solver that shows its decisions

- A real 3D cube: drag to orbit, press a face to turn it, scramble it as deep - as you like. Then ask for the layer-by-layer plan — cross, - corners, middle edges, then orientation and permutation, step by step with - a coach line explaining each. The solver is deterministic and - every move is verified by simulation before it is played. - When several setups reach the next phase in the same number of moves, the - tie goes to the engine — and the rung chip on each step says who picked it. - The seed is optional: leave it empty and every game rolls a fresh random - number, shown so the scramble stays reproducible. + The whole N×N family on one page. Pick a size: 2×2 and 3×3 render as a + real 3D cube you can drag to orbit, with deterministic + step-by-step coaches — every move verified by simulation before + it is played, and genuine ties (which equal-length setup to use) sent to + the engine, with the rung chip saying who picked. From 4×4 up the page + switches to a fast net view and honest free play: scrambles use the full + legal turn set (wide turns included, width adjustable), and the + piece-law validator checks every position as you move — + color counts, piece multiplicity, corner twist, edge flip. The coach + covers what it can prove; where it can't, the page says so instead of + faking a solve. The seed is optional: leave it empty and every game rolls + a fresh random number, shown so the scramble stays reproducible.

-
-
+
+

What decides what

    -
  • The method is a rule, full stop. Layer-by-layer: - cross, first-layer corners, middle edges, edge orientation, corner - orientation, corner permutation, edge permutation, final alignment. - None of that is ever “asked” — it is the fixed skeleton of - the solve, and every candidate insert is verified against the - 54-facelet model before it is played.
  • -
  • The genuine ties are the set-ups. Sune or anti-sune, - Ua or Ub, U-twist before or after: when several alignments reach the - next phase in the same number of moves, the engine arbitrates. The - step's rung chip reads rule (no tie), roster (tie, nobody asked), or - engine (tie, the runtime chose).
  • -
  • A refusal cannot derail the solve. If the engine - declines or is off, the solver takes the first equal option — roster - order — and the rung says so. The plan still verifies.
  • +
  • Two verified cores, honestly split. 3×3 runs the + battle-tested 54-facelet core: layer-by-layer — cross, corners, + middle edges, edge orientation, corner orientation, corner + permutation, edge permutation, final alignment. 2×2 runs its own + core: layer build, the (R′ D′ R D) orientation loop, then a + breadth-first search over the 24 top permutations for a shortest + finish. 4×4–21×21 get no solver claim at all.
  • +
  • Every candidate move is simulation-verified before it is + played. The method is a rule, not a suggestion; an insert + that would disturb a solved piece is never played.
  • +
  • The genuine ties are the set-ups. When several + alignments reach the next phase in the same number of moves, the + engine arbitrates. The step's rung chip reads rule (no tie), roster + (tie, nobody asked), or engine (tie, the runtime chose).
  • +
  • The piece laws run everywhere. Color counts, + piece multiplicity, corner-twist and edge-flip sums are checked on + every position at every size — the laws chip stays green or names + what broke. Wide turns obey the number-first SiGN convention.
  • Determinism is test-pinned. Same seed, same - scramble, same plan — and the pinned PLL algebras (pure edge - 3-cycles, double swaps, corner transpositions) are asserted by - simulation in the test suite.
  • + scramble, same plan — asserted by simulation in the test suite, + including the 2×2 coach over 1,000 seeded scrambles.

Honest edges

    -
  • The solver is beginner layer-by-layer, not a speed solver: expect - ~140 moves, not 20. The point is a plan you can read, not a record.
  • -
  • Turns are instant with a face flash, not tweened layer animations — - the state you see is always the exact facelet state, with no - animation drift between what is shown and what is solved.
  • +
  • The coaches are beginner layer-by-layer, not speed solvers: expect + ~140 moves on 3×3, not 20. The point is a plan you can read, not a + record.
  • +
  • 4×4 and up have no solver. That is deliberate: a reduction or + parity-aware big-cube solver is a real project, and shipping a fake + one would be a lie. Big cubes are free play with live piece-law + validation — which is exactly what a decision runtime does with + problems it can't decide: it says so.
  • +
  • From 4×4 up the cube renders as a 2D net on canvas, not 3D — a + 21×21 has 2,646 stickers and one DOM node each is a dead browser. + Same facelets, same truth, cheap paint.
  • +
  • Mirror, ghost, and sudokube are 3×3 skins — same algebra, same + plan, different piece geometry or sticker print. Fisher, windmill, + megaminx, and square-1 need different cut lines; they are not faked. + Pyraminx, Skewb, and Clock have their own pages with their own + verified cores.
  • A hand move mid-plan drops the rest of the plan on purpose: a plan computed for a state you then changed is a guess wearing a lab coat. Press solve again for a fresh one.
  • @@ -187,6 +204,7 @@

    Honest edges

    + diff --git a/site/cube.js b/site/cube.js index a55eb1e..150427b 100644 --- a/site/cube.js +++ b/site/cube.js @@ -1,30 +1,33 @@ -/* Rubik's Cube shell — a real 3D cube you can orbit, scramble, and turn, - with the LBL coach from cube-core.js stepping through its own solve. - cube-core.js owns the 54-facelet model and the deterministic solver; - this file is chrome: the CSS-3D cube (26 cubies, 54 live stickers), - drag-orbit, move buttons + undo, the step-through coach with rung - chips, the session board, and the engine bridge. Genuine ties (which - of several equal-length setups to use) go to the engine; everything - else — the method itself, the cross, the inserts — is a rule. */ +/* Twisty-cube shell — the N×N family (2×2 … 21×21) plus the 3×3 shape mods, + with the coach that shows its decisions. Two verified cores sit underneath: + cube-core.js owns the battle-tested 3×3 (54-facelet LBL solver), and + cube-n-core.js owns everything else — 2×2 with its own BFS-permuted coach, + and 4×4…21×21 as validated free play (the piece laws are checked on every + move; a solver claim for big cubes would be a lie, so none is made). + Rendering follows the same split: a CSS-3D cube you can orbit at 2×2–3×3, + and a fast 2D net on canvas from 4×4 up (a 21×21 has 2,646 stickers — + DOM-per-sticker dies there). Wide turns (Uw, 3Uw…) get a width stepper at + sizes where they exist. Engine bridge, session board, and meters are + unchanged: genuine ties go to the Decisions SDK engine, everything else + is a rule. */ "use strict"; (function () { const DK = globalThis.DecisionsSDK; const C = globalThis.CubeCore; + const NC = globalThis.CubeNCore; const root = document.getElementById("cube-root"); - if (!root || !DK || !C) return; + if (!root || !DK || !C || !NC) return; const h = DK.shell.h; const byId = (id) => document.getElementById(id); const nowMs = DK.nowMs; - /* ---------- 3D cube geometry (CSS coordinates: x right, y DOWN, z out) */ - const CUBIE = 44; /* cubie face size in px */ - const GAP = 3; /* grid gap */ - const PITCH = CUBIE + GAP; /* grid step */ + /* ---------- shared geometry & palette ---------- */ + const CUBIE = 44; /* cubie face size in px (3D view) */ + const GAP = 3; + const PITCH = CUBIE + GAP; const HALF = CUBIE / 2; - /* plane rotation per face: maps the div's +z onto the face's outward - normal in css space (world x -> css x, world y -> css -y, world z -> css z) */ const FACE_ROT = { F: "", B: "rotateY(180deg) ", R: "rotateY(90deg) ", L: "rotateY(-90deg) ", @@ -35,24 +38,19 @@ D: "#f5f0e6", L: "#f97f1b", B: "#3d7de0", }; const DARK = "#101418"; - /* the 26 cubie positions (center skipped) */ - const CUBIES = []; - for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) for (const z of [-1, 0, 1]) { - if (!x && !y && !z) continue; - CUBIES.push([x, y, z]); - } - /* face divs that carry a real sticker: (cubie, face, facelet index) */ - const STICKER_DIVS = []; /* { el, idx } */ - const FACE_RING = {}; /* face -> [divs] for the turn flash */ - for (const f of C.FACES) FACE_RING[f] = []; + const FACES = C.FACES; + const NORMAL = C.NORMAL; /* ---------- state ---------- */ const S = { + n: 3, /* cube size, 2..21 */ + K: null, /* active core facade (makeK) */ + width: 1, /* turn width in layers (wide turns) */ cfg: { ...C.DEFAULT_CONFIG }, state: null, scramble: [], - history: [], /* every token applied since the scramble */ - plan: null, /* solveLBL result for the step-through */ + history: [], + plan: null, stepIdx: 0, auto: false, autoTimer: 0, speed: 1, solvedAt: 0, @@ -74,17 +72,74 @@ return null; /* refusal and outage both fall back to roster order */ } - /* ---------- cube build ---------- */ - /* ---------- shape mods: mirror & ghost ---------- - Same 3x3 algebra, same solver — only each cubie's EXTENT differs. Sizes - are fixed per position signature (a real mirror/ghost cube is one fixed - physical shape), so rendering stays deterministic and the solve plan - applies verbatim. scale3d keeps every piece centered on its slot. */ + /* ---------- core facade: one interface over the two verified cores. + n === 3 goes to cube-core (the battle-tested 54-facelet LBL solver); + every other size goes to cube-n-core, which coaches 2×2 and validates + the piece laws everywhere. Nothing here guesses. ---------- */ + /* shell-cubie lattice coords: the cores that publish CUBIE_COORDS are + authoritative; cube-core (n=3) gets the classic 26 derived here */ + function shellCoords(core, n) { + if (core.CUBIE_COORDS) return core.CUBIE_COORDS; + const m = n - 1; + const out = []; + for (let x = -m; x <= m; x += 2) { + for (let y = -m; y <= m; y += 2) { + for (let z = -m; z <= m; z += 2) { + if (Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) === m) out.push([x, y, z]); + } + } + } + return out; + } + function makeK(n) { + if (n === 3) { + return { + n, core: C, maxDepth: 1, mods: true, + coords: shellCoords(C, n), + solver: "lbl", + newState(seed, len) { + return C.createGame(C.normalizeConfig({ seed, scrambleLen: len })); + }, + solve(state, chooser) { return C.solveLBL(state, chooser); }, + stamp(cfg) { return C.cubeStamp(cfg); }, + /* cube-core reports { ok, checks: [{ name, ok }] }; normalized here */ + laws(state) { + const v = C.validateState(state); + const bad = (v.checks || []).find((c) => !c.ok); + return { legal: !!v.ok, issue: bad ? bad.name : null }; + }, + }; + } + const core = NC.createCore(n); + return { + n, core, maxDepth: core.maxDepth, mods: false, + coords: shellCoords(core, n), + solver: n === 2 ? "n2" : null, + newState(seed, len) { + return core.createGame({ seed, scrambleLen: len }); + }, + solve(state, chooser) { + const r = core.solve(state, chooser); + return r && !r.refused ? { steps: r.steps, ties: [], moves: r.moves } : null; + }, + stamp(cfg) { return "cubeN|" + n + "|" + cfg.seed + "|" + cfg.scrambleLen; }, + /* cube-n-core reports { legal, issues: [{ check }] } */ + laws(state) { + const v = core.validateState(state); + return { legal: !!v.legal, issue: v.issues && v.issues[0] ? v.issues[0].check : null }; + }, + }; + } + + /* ---------- 3D cube (n = 2, 3) ---------- */ + /* shape mods: mirror & ghost — same 3x3 algebra, same solver — only each + cubie's EXTENT differs. Sizes are fixed per position signature (a real + mirror/ghost cube is one fixed physical shape), so rendering stays + deterministic and the solve plan applies verbatim. scale3d keeps every + piece centered on its slot. */ const SIG_KEY = (x, y, z) => (x ? "1" : "0") + (y ? "1" : "0") + (z ? "1" : "0") + (x > 0 ? "+" : x < 0 ? "-" : "") + (y > 0 ? "+" : y < 0 ? "-" : "") + (z > 0 ? "+" : z < 0 ? "-" : ""); const MIRROR_SIZES = { - /* signature -> [sx, sy, sz]: centers thin, edges split thin/wide, - corners carry the visible stagger a mirror cube is loved for */ "0++": [0.62, 1.02, 1.02], "0--": [0.72, 0.92, 0.92], "0+-": [0.66, 1.04, 0.90], "0-+": [0.70, 0.90, 1.04], "+0+": [1.02, 0.62, 1.02], "-0-": [0.92, 0.72, 0.92], @@ -97,8 +152,6 @@ "--+": [0.94, 1.10, 0.86], "---": [1.22, 1.06, 0.92], }; const GHOST_SIZES = { - /* deliberately irregular — the ghost cube's charm is that nothing - matches; one axis per piece is deliberately oversized */ "0++": [0.58, 1.10, 0.94], "0--": [0.80, 0.86, 1.12], "0+-": [0.70, 0.96, 1.06], "0-+": [0.62, 1.14, 0.88], "+0+": [1.08, 0.60, 0.98], "-0-": [0.88, 0.78, 1.06], @@ -115,10 +168,34 @@ if (S.cfg.cubeType === "ghost") return GHOST_SIZES[SIG_KEY(x, y, z)] || [1, 1, 1]; return [1, 1, 1]; }; + /* sudokube: a fixed, deterministic numbering skin — each face shows 1..9 + exactly once (a solved sudoku box). Pure cosmetics: same algebra, same + plan; the numbers ride the stickers when the cube turns. */ + const sudokuNum = (f, a, b) => ((a * 3 + b + FACES.indexOf(f)) % 9) + 1; + /* grid position of a sticker on its face (row a, col b) — shared by the + sudokube skin and the net renderer. F is the reference frame; each net + face folds out around it. m = n - 1 (lattice half-width). */ + function faceGrid(f, x, y, z, m) { + if (f === "F") return [m - y, x + m]; + if (f === "B") return [m - y, m - x]; + if (f === "R") return [m - y, m - z]; + if (f === "L") return [m - y, z + m]; + if (f === "U") return [z + m, x + m]; + return [m - z, x + m]; /* D */ + } + + let STICKER_DIVS = []; /* { el, idx, f, a, b } */ + let FACE_RING = {}; function buildCube() { + const m = S.n - 1; const cube = h("div", { class: "cube3d" + (S.cfg.cubeType !== "classic" ? " cube3d-mod" : ""), id: "cube3d" }); - for (const [x, y, z] of CUBIES) { + STICKER_DIVS = []; + FACE_RING = {}; + for (const f of FACES) FACE_RING[f] = []; + for (const [rx, ry, rz] of S.K.coords) { + /* raw lattice coords (-m..m step 2) -> css units (-1..1) */ + const x = rx / m, y = ry / m, z = rz / m; const [sx, sy, sz] = cubieScale(x, y, z); const cubie = h("div", { class: "cube3d-cubie", @@ -126,11 +203,10 @@ + (-y * PITCH) + "px," + (z * PITCH) + "px)" + (sx !== 1 || sy !== 1 || sz !== 1 ? " scale3d(" + sx + "," + sy + "," + sz + ")" : ""), }); - for (const f of C.FACES) { - const idx = C.faceletAt(f, x, y, z); - const isOuter = (f === "U" && y === 1) || (f === "D" && y === -1) - || (f === "R" && x === 1) || (f === "L" && x === -1) - || (f === "F" && z === 1) || (f === "B" && z === -1); + for (const f of FACES) { + const nv = NORMAL[f]; + const isOuter = nv[0] * rx + nv[1] * ry + nv[2] * rz === m; + const idx = isOuter ? S.K.core.faceletAt(f, rx, ry, rz) : -1; /* every face of the cubie boxes it: each sits HALF out along its normal (inward-facing ones end up plane-coincident with the neighbor's — identically dark, so no visible z-fight) */ @@ -139,7 +215,11 @@ style: "transform: " + FACE_ROT[f] + "translateZ(" + HALF + "px)", "data-idx": String(idx), }); - if (isOuter) { STICKER_DIVS.push({ el: div, idx }); FACE_RING[f].push(div); } + if (isOuter) { + const [a, b] = faceGrid(f, rx, ry, rz, m); + STICKER_DIVS.push({ el: div, idx, f, a, b }); + FACE_RING[f].push(div); + } cubie.appendChild(div); } cube.appendChild(cubie); @@ -148,47 +228,186 @@ } function rebuildGeometry() { - /* shape mods re-space the pieces without touching state or history */ const cube = byId("cube3d"); if (!cube) return; cube.classList.toggle("cube3d-mod", S.cfg.cubeType !== "classic"); + /* children are the cubies in CUBIE_COORDS order */ const kids = cube.children; - for (let i = 0; i < CUBIES.length; i++) { - const [x, y, z] = CUBIES[i]; + const m = S.n - 1; + const coords = S.K.coords; + for (let i = 0; i < coords.length; i++) { + const [rx, ry, rz] = coords[i]; + const x = rx / m, y = ry / m, z = rz / m; const [sx, sy, sz] = cubieScale(x, y, z); - const t = "translate3d(" + (x * PITCH) + "px," + (-y * PITCH) + "px," + (z * PITCH) + "px)" + kids[i].style.transform = "translate3d(" + (x * PITCH) + "px," + + (-y * PITCH) + "px," + (z * PITCH) + "px)" + (sx !== 1 || sy !== 1 || sz !== 1 ? " scale3d(" + sx + "," + sy + "," + sz + ")" : ""); - kids[i].style.transform = t; } } + function applySkin() { + /* sudokube numbering rides the sticker divs; classic strips it back off */ + const on = S.cfg.cubeType === "sudoku"; + for (const { el, f, a, b } of STICKER_DIVS) { + if (on) { + el.textContent = String(sudokuNum(f, a, b)); + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.justifyContent = "center"; + el.style.fontWeight = "700"; + el.style.fontSize = "19px"; + el.style.color = (f === "U" || f === "D") ? "#101418" : "#ffffff"; + } else if (el.textContent) { + el.textContent = ""; + el.style.display = ""; + el.style.fontWeight = ""; + el.style.fontSize = ""; + el.style.color = ""; + } + } + } + + /* ---------- 2D net (n ≥ 4) ---------- */ + /* A 21×21 has 2,646 live stickers — one DOM node each is a dead browser. + The net is a canvas: six faces in a cross, redrawn per move. Same + facelet indices, same truth, cheap paint. */ + let netCtx = null; + let netGeom = null; /* { cell, gap, off: {face: [px, px]}, size } */ + + function netLayout() { + const n = S.n; + const cell = Math.max(5, Math.min(30, Math.floor(500 / (4 * n)))); + const gap = 1; + const off = { + U: [n * cell + 2 * gap, gap], + L: [gap, n * cell + 2 * gap], + F: [n * cell + 2 * gap, n * cell + 2 * gap], + R: [2 * n * cell + 3 * gap, n * cell + 2 * gap], + B: [3 * n * cell + 4 * gap, n * cell + 2 * gap], + D: [n * cell + 2 * gap, 2 * n * cell + 3 * gap], + }; + return { cell, gap, off, w: 4 * n * cell + 5 * gap, h: 3 * n * cell + 4 * gap }; + } + + function buildNet() { + netGeom = netLayout(); + const cv = h("canvas", { + id: "cube-net", width: String(netGeom.w), height: String(netGeom.h), + "aria-label": S.n + "×" + S.n + " cube, unfolded net view", + role: "img", + }); + cv.style.maxWidth = "100%"; + cv.style.height = "auto"; + cv.style.display = "block"; + cv.style.margin = "0 auto"; + cv.style.borderRadius = "10px"; + cv.style.background = "rgba(0,0,0,0.25)"; + netCtx = cv.getContext("2d"); + return cv; + } + + function drawNet() { + if (!netCtx || !netGeom) return; + const { cell, gap, off } = netGeom; + const m = S.n - 1; + netCtx.clearRect(0, 0, netGeom.w, netGeom.h); + /* direct enumeration: for each face, iterate the shell cubies and keep + the stickers that exist, mapping to (a,b) via faceGrid — one query + per sticker, no grid inversion needed */ + for (const f of FACES) { + const nv = NORMAL[f]; + const [ox, oy] = off[f]; + for (const p of S.K.coords) { + if (nv[0] * p[0] + nv[1] * p[1] + nv[2] * p[2] !== m) continue; + const [a, b] = faceGrid(f, p[0], p[1], p[2], m); + const idx = S.K.core.faceletAt(f, p[0], p[1], p[2]); + netCtx.fillStyle = STICKER[S.state[idx]] || DARK; + netCtx.fillRect(ox + b * (cell + gap), oy + a * (cell + gap), cell, cell); + } + } + } + + /* face flash on the net: outline the turned face's cells (the same + approximation the 3D view makes with its face ring — a turn moves + stickers among many faces, but the turned face is the anchor) */ + let netFlashTimer = 0; + function flashNet(face) { + if (!netCtx || !netGeom) return; + const { cell, gap, off } = netGeom; + const [ox, oy] = off[face]; + netCtx.strokeStyle = "#ffffff"; + netCtx.lineWidth = 2; + for (let a = 0; a < S.n; a++) { + for (let b = 0; b < S.n; b++) { + netCtx.strokeRect(ox + b * (cell + gap) + 1, oy + a * (cell + gap) + 1, cell - 2, cell - 2); + } + } + clearTimeout(netFlashTimer); + netFlashTimer = setTimeout(drawNet, 190); + } + + /* ---------- render ---------- */ function render() { - for (const { el, idx } of STICKER_DIVS) { - el.style.background = STICKER[S.state[idx]] || DARK; + const is3d = S.n <= 3; + if (is3d) { + for (const { el, idx } of STICKER_DIVS) { + el.style.background = STICKER[S.state[idx]] || DARK; + } + } else { + drawNet(); } - const solved = C.verifySolved(S.state).solved; + const solved = S.K.core.verifySolved(S.state).solved; const chip = byId("cube-solved-chip"); if (chip) { chip.textContent = solved ? "solved" : "scrambled"; chip.classList.toggle("on", solved); } + const laws = byId("cube-laws-chip"); + if (laws) { + const v = S.K.laws(S.state); + laws.textContent = v.legal ? "piece laws hold" : "laws: " + v.issue; + laws.classList.toggle("on", v.legal); + laws.classList.toggle("off", !v.legal); + } byId("cube-progress").textContent = S.plan ? "step " + Math.min(S.stepIdx + 1, S.plan.steps.length) + "/" + S.plan.steps.length : (S.history.length ? S.history.length + " moves from the scramble" : "ready"); - byId("cube-solve-btn").disabled = solved; + const solveBtn = byId("cube-solve-btn"); + const playBtn = byId("cube-play-btn"); + if (solveBtn) solveBtn.disabled = solved || !S.K.solver; + if (playBtn) playBtn.disabled = solved || !S.K.solver; renderSteps(); } /* ---------- moves ---------- */ + function tokInfo(tok) { + /* number-first SiGN: [1-21]?[URFDLB]w?[2']? */ + const mt = /^(\d+)?([URFDLB])(w)?(2|')?$/.exec(tok); + if (!mt) return null; + return { + face: mt[2], + width: mt[1] ? parseInt(mt[1], 10) : (mt[3] ? 2 : 1), + }; + } + function invertTok(tok) { + if (tok.endsWith("2")) return tok; + if (tok.endsWith("'")) return tok.slice(0, -1); + return tok + "'"; + } + function applyTok(tok, record) { - S.state = C.applyMove(S.state, tok); + S.state = S.K.core.applyMove(S.state, tok); if (record !== false) S.history.push(tok); - flash(tok[0]); + if (S.n <= 3) flash(tok[0]); + else { + const info = tokInfo(tok); + if (info) flashNet(info.face); + } renderMoveLog(); } - /* newest on top, newest highlighted; the rail scrolls (themed scrollbar) - so long solves never blow the layout up */ + /* newest on top, newest highlighted; the rail scrolls so long solves + never blow the layout up */ function renderMoveLog() { const log = byId("cube-movelog"); if (!log) return; @@ -224,18 +443,18 @@ if (S.auto) stopAuto(); const tok = S.history.pop(); if (!tok) return; - const inv = tok.endsWith("2") ? tok : tok.endsWith("'") ? tok[0] : tok[0] + "'"; - S.state = C.applyMove(S.state, inv); - flash(inv[0]); + const inv = invertTok(tok); + S.state = S.K.core.applyMove(S.state, inv); + if (S.n <= 3) flash(inv[0]); S.plan = null; S.stepIdx = 0; render(); } let flashTimer = 0; function flash(face) { - for (const el of FACE_RING[face]) el.classList.add("flash"); + for (const el of FACE_RING[face] || []) el.classList.add("flash"); clearTimeout(flashTimer); flashTimer = setTimeout(() => { - for (const f of C.FACES) for (const el of FACE_RING[f]) el.classList.remove("flash"); + for (const f of FACES) for (const el of FACE_RING[f]) el.classList.remove("flash"); }, 190); } function coach(text) { byId("cube-coach").textContent = text; } @@ -243,13 +462,25 @@ /* ---------- solve + step-through ---------- */ function solve() { if (S.auto) stopAuto(); - const solved = C.verifySolved(S.state).solved; + if (!S.K.solver) { + coach(S.n + "×" + S.n + " has no coach — free play with live piece-law " + + "validation. The solver covers 2×2 and 3×3; pretending otherwise " + + "would be a lie in a solver that verifies every move."); + return; + } + const solved = S.K.core.verifySolved(S.state).solved; if (solved) { coach("already solved — scramble it first."); return; } const t0 = nowMs(); - S.plan = C.solveLBL(S.state, engine.status === "ready" ? engineChoose : undefined); + S.plan = S.K.solve(S.state, engine.status === "ready" ? engineChoose : undefined); S.meters.sample("solve.ms", nowMs() - t0); S.stepIdx = 0; - const ties = S.plan.ties.filter((t) => t.source === "engine").length; + if (!S.plan) { + coach("the solver refused this position (" + S.n + "×" + S.n + + ") — that should be impossible for a legal scramble; it is logged, not hidden."); + render(); + return; + } + const ties = S.plan.ties ? S.plan.ties.filter((t) => t.source === "engine").length : 0; coach("plan ready: " + S.plan.moves.length + " moves in " + S.plan.steps.length + " steps (" + ties + " engine ties). Step through it, or let it play."); buildStepList(); @@ -277,15 +508,15 @@ S.stepIdx--; const st = S.plan.steps[S.stepIdx]; for (let i = st.moves.length - 1; i >= 0; i--) { - const t = st.moves[i]; - const inv = t.endsWith("2") ? t : t.endsWith("'") ? t[0] : t[0] + "'"; - S.state = C.applyMove(S.state, inv); - flash(inv[0]); + const inv = invertTok(st.moves[i]); + S.state = S.K.core.applyMove(S.state, inv); + if (S.n <= 3) flash(inv[0]); } coach("stepped back: " + st.title); render(); } function startAuto() { + if (!S.K.solver) { solve(); return; } if (!S.plan) solve(); if (!S.plan) return; S.auto = true; @@ -308,10 +539,11 @@ function recordBoard() { S.board.record({ ts: Date.now(), outcome: "solved", + n: S.n, moves: S.history.length, steps: S.planSteps || 0, seed: S.cfg.seed, - stamp: C.cubeStamp(S.cfg), + stamp: S.K.stamp(S.cfg), }); } @@ -355,34 +587,90 @@ } /* ---------- game lifecycle ---------- */ + function readSize() { + const sel = byId("cube-size"); + const n = sel ? parseInt(sel.value, 10) : 3; + return Number.isInteger(n) && n >= 2 && n <= 21 ? n : 3; + } + function newGame() { stopAuto(); const raw = byId("cube-seed").value.trim(); /* optional numeric seed: empty means a fresh random number every game */ const seed = raw === "" ? DK.randomSeed() : String(parseInt(raw, 10) || DK.randomSeed()); byId("cube-seed").value = seed; - S.cfg = C.normalizeConfig({ + /* shape mods are 3×3 physical puzzles: any other size plays classic */ + S.cfg = { seed, scrambleLen: +byId("cube-len").value, - cubeType: byId("cube-type") ? byId("cube-type").value : "classic", - }); - const g = C.createGame(S.cfg); + cubeType: S.K.mods ? S.cfg.cubeType : "classic", + }; + const g = S.K.newState(seed, S.cfg.scrambleLen); S.state = g.state; S.scramble = g.scramble.slice(); S.history = []; S.plan = null; S.stepIdx = 0; S.solvedAt = 0; - coach("scrambled with " + S.scramble.length + " turns (seed " + seed - + "). Turn it by hand, or ask for the layer-by-layer plan."); + const what = S.K.solver + ? "Turn it by hand, or ask for the " + (S.n === 3 ? "layer-by-layer" : "beginner") + " plan." + : "Free play — every position is checked against the piece laws as you move."; + coach("scrambled with " + S.scramble.length + " turns (seed " + seed + "). " + what); buildStepList(); renderMoveLog(); render(); } + function resetParams() { - byId("cube-len").value = String(C.DEFAULT_CONFIG.scrambleLen); + byId("cube-len").value = String(S.K.core.defaultScrambleLen + ? S.K.core.defaultScrambleLen(S.n) : C.DEFAULT_CONFIG.scrambleLen); byId("cube-seed").value = ""; byId("cube-type").value = "classic"; newGame(); } + /* rebuild everything below the bar when the size changes: cores, + renderers, and controls all follow n */ + function rebuildForSize(n) { + S.n = n; + S.K = makeK(n); + S.width = 1; + const stage = byId("cube-stage"); + stage.textContent = ""; + stage.appendChild(n <= 3 + ? h("div", { class: "cube3d-scene", id: "cube-scene" }, + h("div", { class: "cube3d-orbit" }, buildCube())) + : buildNet()); + if (n <= 3) { + wireOrbit(byId("cube-scene")); + applyOrbit(); + applySkin(); + } + buildMovePad(); + /* shape mods exist only at 3×3: reflect that in the type select */ + const typeSel = byId("cube-type"); + for (const opt of typeSel.options) { + if (opt.value !== "classic") opt.disabled = !S.K.mods; + } + if (!S.K.mods && typeSel.value !== "classic") typeSel.value = "classic"; + S.cfg.cubeType = typeSel.value; + const widthWrap = byId("cube-width-wrap"); + widthWrap.hidden = S.K.maxDepth < 2; + if (S.K.maxDepth >= 2) { + const w = byId("cube-width"); + w.max = String(S.K.maxDepth); + w.value = "1"; + byId("cube-width-val").textContent = "1"; + } + const note = byId("cube-solver-note"); + note.textContent = S.K.solver + ? (n === 2 + ? "2×2 runs its own verified coach: layer build, corner orientation, " + + "then a shortest-search finish over the 24 top permutations." + : "3×3 runs the battle-tested layer-by-layer coach on the 54-facelet core.") + : n + "×" + n + ": free play. Every move is validated against the piece " + + "laws (color counts, piece multiplicity, corner twist, edge flip) — " + + "the coach covers 2×2 and 3×3, and a fake plan for big cubes would " + + "be a lie. Watch the laws chip stay green while you scramble."; + } + /* ---------- orbit ---------- */ function wireOrbit(scene) { scene.addEventListener("pointerdown", (e) => { @@ -403,7 +691,8 @@ scene.addEventListener("pointercancel", up); } function applyOrbit() { - byId("cube3d").style.transform = + const el = byId("cube3d"); + if (el) el.style.transform = "rotateX(" + S.orbit.rx + "deg) rotateY(" + S.orbit.ry + "deg)"; } @@ -411,12 +700,41 @@ function moveBtn(tok) { return h("button", { class: "mz-chip cube-move", type: "button" }, tok); } + function wideTok(face, suffix) { + if (S.width === 1) return face + suffix; + return (S.width > 2 ? String(S.width) : "") + face + "w" + suffix; + } + /* the move pad regenerates whenever size or turn width changes */ + function buildMovePad() { + const pad = byId("cube-moves"); + pad.textContent = ""; + for (const f of ["U", "R", "F", "D", "L", "B"]) { + pad.appendChild(moveBtn(wideTok(f, ""))); + pad.appendChild(moveBtn(wideTok(f, "'"))); + pad.appendChild(moveBtn(wideTok(f, "2"))); + } + const note = byId("cube-moves-note"); + note.textContent = S.width === 1 + ? "Each button turns one face a quarter turn clockwise (viewed from " + + "that face), counter-clockwise (prime), or a half turn (2). " + + (S.n <= 3 ? "Drag the cube to orbit." : "") + : "Buttons turn " + S.width + " layer" + (S.width > 1 ? "s" : "") + " at once (SiGN wide " + + "moves" + (S.width > 2 ? ", number-first" : "") + ")."; + } function build() { const deepSeed = new URLSearchParams(window.location.search).get("seed"); if (deepSeed) S.cfg.seed = String(parseInt(deepSeed, 10) || DK.randomSeed()); + const sizeSel = h("select", { id: "cube-size", class: "mz-input", "aria-label": "cube size" }); + for (let n = 2; n <= 21; n++) { + const label = n + "×" + n + (n === 2 ? " (coach)" : n === 3 ? " (coach + mods)" : " (free play)"); + sizeSel.appendChild(h("option", { value: String(n) }, label)); + } + sizeSel.value = "3"; + const bar = h("div", { class: "game-bar" }, + sizeSel, h("button", { class: "mz-chip", id: "cube-scramble-btn", type: "button" }, "⟲ scramble"), h("button", { class: "mz-chip", id: "cube-solve-btn", type: "button" }, "solve"), h("button", { class: "mz-chip mz-play", id: "cube-play-btn", type: "button" }, "▶ auto-solve"), @@ -430,6 +748,7 @@ h("button", { class: "mz-chip", id: "cube-dice", type: "button", title: "roll a new random seed" }, "🎲")), h("span", { class: "mz-chip game-rung", id: "cube-solved-chip" }, "scrambled"), + h("span", { class: "mz-chip game-rung", id: "cube-laws-chip" }, "piece laws hold"), h("button", { class: "mz-chip", id: "cube-reset-btn", type: "button", title: "restore default parameters" }, "reset params"), h("button", { class: "mz-chip", id: "cube-fs-btn", type: "button" }, "⛶ full screen"), @@ -439,11 +758,11 @@ h("button", { class: "mz-chip", id: "cube-stats-btn", type: "button" }, "ⓘ stats"), ); - const scene = h("div", { class: "cube3d-scene", id: "cube-scene" }, - h("div", { class: "cube3d-orbit" }, buildCube())); + const stage = h("div", { id: "cube-stage" }); const under = h("div", { class: "mz-under game-under" }, h("span", { class: "mz-kv" }, h("b", { id: "cube-progress" }, "ready"))); const coachLine = h("p", { class: "game-hintline", id: "cube-coach", "aria-live": "polite" }, ""); + const solverNote = h("p", { class: "game-note", id: "cube-solver-note" }, ""); const stepPane = h("div", null, h("div", { class: "cube-steplist", id: "cube-steplist" }), @@ -456,33 +775,35 @@ { id: "play", label: "Turn the cube", open: true, kids: [ - h("div", { class: "cube-moves" }, - ...["U", "R", "F", "D", "L", "B"].flatMap((f) => [ - moveBtn(f), moveBtn(f + "'"), moveBtn(f + "2"), - ])), - h("p", { class: "game-note" }, - "Each button turns one face a quarter turn clockwise (viewed from ", - "that face), counter-clockwise (prime), or a half turn (2). Drag ", - "the cube to orbit. A hand move mid-plan drops the rest of the ", - "plan — solve again for a fresh one."), + h("div", { class: "cube-moves", id: "cube-moves" }), + h("p", { class: "game-note", id: "cube-moves-note" }, ""), + h("div", { class: "game-slider", id: "cube-width-wrap", hidden: "" }, + h("span", { class: "game-slider-label" }, "turn width (wide moves)"), + h("input", { type: "range", id: "cube-width", min: "1", max: "2", + step: "1", value: "1", "aria-label": "turn width in layers" }), + h("span", { class: "game-sliderval", id: "cube-width-val" }, "1")), ], }, { id: "setup", label: "Advanced — shape, scramble & solve", kids: [ h("label", { class: "game-field" }, - h("span", { class: "game-slider-label" }, "cube type"), + h("span", { class: "game-slider-label" }, "cube type (3×3)"), h("select", { id: "cube-type", "aria-label": "cube shape mod" }, h("option", { value: "classic" }, "classic — the standard cube"), h("option", { value: "mirror" }, "mirror — staggered piece depths, same algebra"), - h("option", { value: "ghost" }, "ghost — irregular piece depths, same algebra")), + h("option", { value: "ghost" }, "ghost — irregular piece depths, same algebra"), + h("option", { value: "sudoku" }, "sudokube — numbered stickers, same algebra")), h("span", { class: "game-note" }, - "Mirror and ghost are shape mods: the move algebra, scramble, ", - "and the whole layer-by-layer solver are IDENTICAL — only the ", - "piece geometry differs, exactly like the physical puzzles. ", - "Pyraminx and megaminx are different puzzle families with ", - "different move laws — not shipped; pretending otherwise would ", - "be a lie in a solver that verifies every move.")), + "Mirror, ghost, and sudokube are 3×3 skins: the move algebra, ", + "scramble, and the whole solver are IDENTICAL — only the piece ", + "geometry (or the sticker print) differs, exactly like the ", + "physical puzzles. Other sizes play classic. Pyraminx, Skewb, ", + "and Clock are different puzzle families with their own ", + "verified cores and pages; megaminx, square-1, fisher, and ", + "windmill need cut lines this renderer doesn't have — not ", + "shipped, and pretending otherwise would be a lie in a solver ", + "that verifies every move.")), h("label", { class: "game-slider" }, h("span", { class: "game-slider-label" }, "scramble length"), h("input", { type: "range", id: "cube-len", min: "1", max: "40", @@ -491,22 +812,25 @@ h("p", { class: "game-note" }, "The seed is optional: leave it empty and every game rolls a fresh ", "random number (shown in the bar so the run stays reproducible — ", - "type it back in to replay the exact scramble)."), + "type it back in to replay the exact scramble). Scrambles use the ", + "full legal turn set for the chosen size, wide turns included."), ], }, { id: "about", label: "What decides what?", kids: [h("p", { class: "game-note" }, - "The solver is layer-by-layer: cross, first-layer corners, middle ", + "The 3×3 solver is layer-by-layer: cross, first-layer corners, middle ", "edges, then edge orientation, corner orientation, corner ", - "permutation, edge permutation, and a final alignment. Almost all of ", - "it is RULE — the method is fixed and every insert is verified by ", - "simulation before it is played. The genuine ties are the set-ups: ", - "when several alignments reach the next phase in the same number of ", - "moves, the engine picks one (and a refusal falls back to roster ", - "order without derailing the solve). Every move the plan plays is ", - "checked against the facelet model — the coach can't lie you into a ", - "wrong cube.")], + "permutation, edge permutation, and a final alignment. The 2×2 coach ", + "builds a layer, orients corners with the (R' D' R D) loop, then ", + "searches a shortest finish over the 24 top permutations. Almost all ", + "of it is RULE — the method is fixed and every candidate move is ", + "verified by simulation before it is played. The genuine ties are ", + "the set-ups: when several alignments reach the next phase in the ", + "same number of moves, the engine picks one (and a refusal falls ", + "back to roster order without derailing the solve). Every move the ", + "plan plays is checked against the facelet model — the coach can't ", + "lie you into a wrong cube.")], }, ]); @@ -517,13 +841,15 @@ h("p", { class: "game-note" }, "no moves yet"))); const main = h("div", { class: "game-layout" }, - h("div", { class: "game-center" }, scene, under, coachLine), + h("div", { class: "game-center" }, stage, under, coachLine, solverNote), h("div", { class: "game-side" }, opts, moveLog, stepPane)); root.appendChild(h("div", { class: "game-wrap", id: "cube-wrap" }, bar, main)); buildOverlays(); wire(); engine.setEnabled(true); + S.K = makeK(readSize()); + rebuildForSize(S.n); newGame(); /* boots paused — solve/auto-solve is the play button */ } @@ -553,9 +879,10 @@ } body.appendChild(h("table", { class: "game-table" }, h("thead", null, h("tr", null, - ...["#", "moves", "seed", "when"].map((x) => h("th", null, x)))), + ...["#", "size", "moves", "seed", "when"].map((x) => h("th", null, x)))), h("tbody", null, rows.map((r, i) => h("tr", null, h("td", null, String(rows.length - i)), + h("td", null, r.n ? r.n + "×" + r.n : "3×3"), h("td", null, String(r.moves)), h("td", null, r.seed), h("td", null, new Date(r.ts).toLocaleTimeString())))))); @@ -569,6 +896,7 @@ body.textContent = ""; const m = S.meters.snapshot(); const kv = (label, val) => h("p", { class: "game-note" }, label + " " + val); + body.appendChild(kv("cube size:", S.n + "×" + S.n + " (turn width " + S.width + ")")); body.appendChild(kv("engine calls:", String(m["engine.calls"] || 0))); body.appendChild(kv("engine latency avg:", (m["engine.ms"] ? m["engine.ms"].avg.toFixed(1) : "0") + " ms")); body.appendChild(kv("solve compute avg:", (m["solve.ms"] ? m["solve.ms"].avg.toFixed(1) : "0") + " ms")); @@ -577,6 +905,10 @@ /* ---------- wire ---------- */ function wire() { + byId("cube-size").addEventListener("change", () => { + rebuildForSize(readSize()); + newGame(); + }); byId("cube-scramble-btn").addEventListener("click", () => { newGame(); }); byId("cube-solve-btn").addEventListener("click", solve); byId("cube-play-btn").addEventListener("click", () => (S.auto ? stopAuto() : startAuto())); @@ -592,12 +924,21 @@ byId("cube-len-val").textContent = byId("cube-len").value; }); byId("cube-len").addEventListener("change", newGame); + byId("cube-width").addEventListener("input", () => { + S.width = parseInt(byId("cube-width").value, 10) || 1; + byId("cube-width-val").textContent = String(S.width); + buildMovePad(); + }); byId("cube-type").addEventListener("change", () => { - /* a shape mod is physical geometry: apply to the CURRENT cube without - rescrambling — state, history, and any plan all stay valid */ + /* a shape mod is physical geometry (or print): apply to the CURRENT + cube without rescrambling — state, history, and any plan stay valid */ S.cfg.cubeType = byId("cube-type").value; - rebuildGeometry(); - coach(byId("cube-type").value + " shape applied — same algebra, same plan."); + if (S.n <= 3) { + rebuildGeometry(); + applySkin(); + render(); + } + coach(S.cfg.cubeType + " skin applied — same algebra, same plan."); }); byId("cube-reset-btn").addEventListener("click", resetParams); byId("cube-engine-toggle").addEventListener("click", () => { @@ -609,13 +950,13 @@ byId("cube-board-close").addEventListener("click", () => byId("cube-board-overlay").close()); byId("cube-stats-btn").addEventListener("click", () => { renderStats(); byId("cube-stats-overlay").showModal(); }); byId("cube-stats-close").addEventListener("click", () => byId("cube-stats-overlay").close()); - for (const btn of root.querySelectorAll(".cube-move")) { - btn.addEventListener("click", () => userMove(btn.textContent)); - } - wireOrbit(byId("cube-scene")); + /* the move pad regenerates on size/width changes — delegate clicks */ + byId("cube-moves").addEventListener("click", (e) => { + const btn = e.target.closest("button.cube-move"); + if (btn) userMove(btn.textContent); + }); DK.shell.fullscreen(byId("cube-wrap"), byId("cube-fs-btn")); setChip(); - applyOrbit(); } function setChip() { diff --git a/site/playground.html b/site/playground.html index 731b5d0..4538b4f 100644 --- a/site/playground.html +++ b/site/playground.html @@ -381,18 +381,72 @@

    Maze Solver & Dungeon Crawler

-
+
playable live -

Rubik's Cube

-

A real 3D cube with a layer-by-layer solver that shows its plan step by step.

+

Twisty Cubes 2×2–21×21

+

The whole N×N family: orbit the 3D view, or paint big cubes as a net — with honest coaches where they exist.

▶ Play click the card for details →
-

Drag to orbit, turn faces by hand, scramble, and ask for the plan: a deterministic layer-by-layer solver where every move is simulation-verified before it plays. The genuine ties (which equal-length setup) go to the engine, and each step's rung chip says rule, roster, or engine.

- scramble it and ask for the plan → +

2×2 and 3×3 get real coaches (the 3×3 layer-by-layer plan, the 2×2 layer build and BFS finish) with every move simulation-verified and genuine ties sent to the engine. 4×4–21×21 are validated free play: wide turns with an adjustable width, and the piece-law validator checking every position. Mirror, ghost, and sudokube skins at 3×3.

+ pick a size and scramble → +
+
+
+
+
+
+ playable live +

Rubik's Clock

+

18 dials, 4 pins, one flip — and exactly one solution per legal position, computed by elimination.

+ ▶ Play + click the card for details → +
+
+

The WCA clock: WCA-shaped scrambles (five wheels a side around a y2, TNoodle amounts), wheel turns coupled by the pin law the scrambler itself uses, and a coach that solves by Gauss-Jordan over Z₁₂ and replay-verifies every turn. The group is abelian — no ties exist, so the engine is deliberately absent.

+ set the clocks to 12:00 → +
+
+
+
+
+
+ soon +

Pyraminx

+

WCA tetrahedron: tips, corners, edges. A verified core is in test — the page lands with it.

+ in the workshop → +
+
+

The same bar as everything here: a move table pinned against the puzzle's real laws, a solver that verifies every move by simulation, and no faked solve. Not a clickable picture of a Pyraminx.

+
+
+
+
+
+
+ soon +

Skewb

+

WCA corner-turn cube. A verified core is in test — the page lands with it.

+ in the workshop → +
+
+

Looks like a cube, turns like nothing else: eight corner axes, a different group entirely. It gets its own verified core and its own honest page.

+
+
+
+
+
+
+ soon +

Megaminx & Square-1

+

Dodecahedron faces and slice geometry — the two hardest renders in the family.

+ designed, not faked → +
+
+

Both need cut-line geometry this renderer family doesn't have yet. They ship when they can be verified — a clickable picture that lies about its moves would break the one promise this playground makes.

diff --git a/site/tests/clock_test.mjs b/site/tests/clock_test.mjs new file mode 100644 index 0000000..95aa153 --- /dev/null +++ b/site/tests/clock_test.mjs @@ -0,0 +1,283 @@ +/* Rubik's Clock solver — core suite. Import order matters: the SDK first, + then clock-core; read the globals. + + Spec-pinned tests: the derived move table equals the WCA scrambler's + reference rows (tnoodle-lib ClockPuzzle.java) and satisfies the + documented pin identities (4 dials around each up pin, reverse side + sees only the up-pin corner dials); moves commute (Jaap: the clock's + positions form an Abelian group); y2 is an involution that conjugates + each move to its swapped-half image; the four corner-pair laws are + preserved by every move and exactly characterize the reachable set; + the solver's published-order plan lands every seeded scramble on + all-zeros with every replayed step law-legal; invalid states are + refused with named reasons. + + Run: node --test site/tests/clock_test.mjs */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const siteDir = path.dirname(fileURLToPath(import.meta.url)); +await import(path.join(siteDir, '..', 'sdk', 'decisions-sdk.js')); +await import(path.join(siteDir, '..', 'clock-core.js')); +const K = globalThis.ClockCore; +const DK = globalThis.DecisionsSDK; + +const SOLVED = K.SOLVED; +const rand = DK.makeRng("clock-test"); +const randomWalkState = (steps) => { + /* random walk from solved: law-legal by construction */ + let s = SOLVED.slice(); + for (let i = 0; i < steps; i++) { + const m = K.MOVES[Math.floor(rand() * K.MOVES.length)]; + s = K.applyMove(s, m + Math.floor(rand() * 12) + "+"); + } + return s; +}; +const randState = () => randomWalkState(8); + +test('solved state verifies; a scrambled one does not; round-trip closes', () => { + assert.equal(K.verifySolved(SOLVED.slice()).solved, true); + const g = K.createGame({ seed: "spec" }); + assert.equal(K.verifySolved(g.state).solved, false); + assert.equal(K.validateState(g.state).ok, true); + const r = K.solve(g.state); + assert.equal(K.verifySolved(r.final).solved, true); + assert.deepEqual(K.applyAlg(g.state, r.moves), r.final, "replay drift"); +}); + +test('derived table equals the WCA scrambler reference and the pin identities', () => { + /* load already aborts on mismatch; pin the equality explicitly */ + for (const m of K.MOVES) { + assert.deepEqual(K.MOVE_VEC[m], K.REFERENCE[m], m + " row drifted from tnoodle"); + } + /* wiki: a dial turned next to its up pin turns "the 4 mini clocks + around this pin" — the front set is the union of the around-pin + sets, each dial counted once */ + for (const m of K.MOVES) { + const want = new Set(); + for (const pin of K.PINS_UP[m]) for (const d of K.AROUND[pin]) want.add(d); + for (let d = 0; d < 9; d++) { + assert.equal(K.MOVE_VEC[m][d], want.has(d) ? 1 : 0, m + " front dial " + d); + } + } + /* Jaap: turn a corner next to an up button and "none of the clocks on + the reverse side move, except for the corner clocks" — exactly one + back entry, at the same physical corner's back dial, opposite sign */ + for (const m of K.MOVES) { + const backEntries = []; + for (let d = 9; d < 18; d++) if (K.MOVE_VEC[m][d] !== 0) backEntries.push(d); + const upCorners = K.PINS_UP[m].map((p) => K.CORNER_PAIRS[p][1]).sort((a, b) => a - b); + assert.deepEqual(backEntries, upCorners, m + " reverse-side dials"); + for (const d of backEntries) assert.equal(K.MOVE_VEC[m][d], -1, m + " back dial " + d); + } +}); + +test('commutativity: wheel moves are additive and commute (Jaap: Abelian)', () => { + for (let i = 0; i < 60; i++) { + const s = randState(); + const a = K.MOVES[Math.floor(rand() * 9)] + Math.floor(rand() * 12) + "+"; + const b = K.MOVES[Math.floor(rand() * 9)] + Math.floor(rand() * 12) + "+"; + assert.deepEqual(K.applyMove(K.applyMove(s, a), b), + K.applyMove(K.applyMove(s, b), a), a + " vs " + b); + } + /* and longer sequences: any permutation of the same WHEEL tokens + agrees — y2 is a puzzle flip, not a translation, so it stays out */ + const s = randState(); + const toks = ["UR3+", "D2-", "ALL5+", "UL4-", "R6-", "L1+"]; + for (let i = 0; i < 12; i++) { + const shuffled = toks.slice(); + for (let j = shuffled.length - 1; j > 0; j--) { + const p = Math.floor(rand() * (j + 1)); + [shuffled[j], shuffled[p]] = [shuffled[p], shuffled[j]]; + } + assert.deepEqual(K.applyAlg(s, shuffled.join(" ")), K.applyAlg(s, toks.join(" "))); + } +}); + +test('y2 is an involution and conjugates each move to its swapped-half image', () => { + const s = randState(); + assert.deepEqual(K.applyAlg(s, "y2 y2"), s, "y2 must be its own inverse"); + for (const m of K.MOVES) { + const tok = m + "3+"; + /* moving the flipped puzzle = flipping the puzzle moved by the + swapped-half vector: T_g . S = S . T_{S(g)} */ + const lhs = K.applyMove(K.swapHalves(s), tok); + const rhs = K.swapHalves(K.applyVec(s, K.swapVec(K.MOVE_VEC[m]), 3)); + assert.deepEqual(lhs, rhs, "conjugation " + m); + } +}); + +test('corner-pair laws: every move preserves them from any law-legal state', () => { + const pairSum = (s, k) => (((s[K.CORNER_PAIRS[k][0]] + s[K.CORNER_PAIRS[k][1]]) % 12) + 12) % 12; + for (let i = 0; i < 40; i++) { + const s = randState(); + for (const k of K.PAIR_LAWS) assert.equal(pairSum(s, k), 0, "walk left " + k + " unsolved"); + for (const m of K.MOVES) { + const t = K.applyMove(s, m + Math.floor(rand() * 12) + "+"); + for (const k of K.PAIR_LAWS) assert.equal(pairSum(t, k), 0, m + " broke " + k); + } + const flipped = K.swapHalves(s); + for (const k of K.PAIR_LAWS) assert.equal(pairSum(flipped, k), 0, "y2 broke " + k); + } +}); + +test('solver sweep: 520 seeded scrambles all close, verified and law-legal', () => { + let totalWheel = 0, maxWheel = 0; + const RUNS = 520; + for (let i = 0; i < RUNS; i++) { + const g = K.createGame({ seed: "sweep-" + i, scrambleLen: 6 }); + const r = K.solve(g.state); + assert.equal(r.verified.solved, true, "seed sweep-" + i + " not solved"); + assert.equal(r.legal, true, "seed sweep-" + i + " illegal"); + assert.deepEqual(K.applyAlg(g.state, r.moves), r.final, "seed sweep-" + i + " replay drift"); + assert.ok(r.wheelMoves <= 14, "seed sweep-" + i + " used " + r.wheelMoves + " wheel moves"); + for (const st of r.steps) { + assert.equal(K.validateState(st.stateAfter).ok, true, + "seed sweep-" + i + " illegal intermediate"); + } + totalWheel += r.wheelMoves; + if (r.wheelMoves > maxWheel) maxWheel = r.wheelMoves; + } + const mean = totalWheel / RUNS; + console.log(`solve sweep: ${RUNS} scrambles, mean ${mean.toFixed(3)} wheel moves, max ${maxWheel}`); + assert.ok(mean < 15, "mean wheel moves " + mean + " regressed"); + /* random-walk states (not just scrambles) close too */ + for (let i = 0; i < 60; i++) { + const s = randomWalkState(1 + Math.floor(rand() * 25)); + const r = K.solve(s); + assert.equal(K.verifySolved(r.final).solved, true, "walk " + i + " not solved"); + } +}); + +test('solver order follows the published plan phases', () => { + const g = K.createGame({ seed: "phases", scrambleLen: 9 }); + const r = K.solve(g.state); + const order = K.SOLVE_PLAN.map((p) => p.phase); + let seen = new Set(); + let lastIdx = -1; + for (const st of r.steps) { + if (st.phase.endsWith("-flip")) continue; + const pi = order.indexOf(st.phase); + assert.ok(pi >= 0, "unknown phase " + st.phase); + assert.ok(pi >= lastIdx, "phase regression at " + st.phase); + if (pi > lastIdx) seen.add(st.phase); + lastIdx = pi; + } + const used = r.wheelMoves > 0; + assert.equal(used, true); +}); + +test('validator rejections: out-of-range, non-integer, wrong shape, broken pairs', () => { + const fails = (state, name, frag) => { + const v = K.validateState(state); + assert.equal(v.ok, false, "expected rejection: " + name); + const hit = v.checks.find((c) => !c.ok && c.reason.includes(frag)); + assert.ok(hit, "no check named for " + name + " mentioning " + frag); + return v; + }; + const bad13 = SOLVED.slice(); bad13[4] = 13; + fails(bad13, "dial 13", "reads 13"); + const badNeg = SOLVED.slice(); badNeg[9] = -1; + fails(badNeg, "dial -1", "reads -1"); + const badFloat = SOLVED.slice(); badFloat[3] = 2.5; + fails(badFloat, "float dial", "2.5"); + const badStr = SOLVED.slice(); badStr[7] = "3"; + fails(badStr, "string dial", "reads 3"); + fails([0, 0, 0], "short state", "18 dial readings"); + fails(SOLVED.slice().concat([0]), "long state", "18 dial readings"); + fails("nope", "non-array", "18 dial readings"); + /* break one corner pair in range: front UR +1 alone */ + const badPair = randomWalkState(6).slice(); + badPair[K.CORNER_PAIRS.UR[0]] = (badPair[K.CORNER_PAIRS.UR[0]] + 1) % 12; + const v = fails(badPair, "broken UR pair", "corner UR"); + assert.equal(v.checks.find((c) => c.name === "corner-pair-UR").ok, false); +}); + +test('move algebra: parse forms, mod-12 equivalence, refusals', () => { + assert.deepEqual(K.parseMove("UR3+"), { name: "UR", amount: 3 }); + assert.deepEqual(K.parseMove("UR2-"), { name: "UR", amount: 10 }); + assert.deepEqual(K.parseMove("DL+"), { name: "DL", amount: 1 }); + assert.deepEqual(K.parseMove("DL-"), { name: "DL", amount: 11 }); + assert.deepEqual(K.parseMove("ALL8-"), { name: "ALL", amount: 4 }); + assert.deepEqual(K.parseMove("y2"), { name: "y2", amount: 0 }); + /* full turns vanish; complement spellings agree */ + const s = randState(); + assert.deepEqual(K.applyMove(s, "UR12+"), s); + assert.deepEqual(K.applyMove(s, "UR0+"), s); + assert.deepEqual(K.applyMove(s, "UR4-"), K.applyMove(s, "UR8+")); + assert.deepEqual(K.applyMove(s, "ALL6+"), K.applyMove(s, "ALL6-")); + /* whitespace-separated sequences */ + assert.deepEqual(K.applyAlg(s, " y2 UL4- "), K.applyMove(K.applyMove(s, "y2"), "UL4-")); + for (const bad of ["", "XX3+", "ur3+", "UR", "UR3", "UR3*", "y", "y3", "UR-3", "UR++"]) { + assert.throws(() => K.parseMove(bad), undefined, "token " + JSON.stringify(bad) + " should refuse"); + } + /* UR-3 style is deliberately not in the grammar; the sign trails */ + assert.deepEqual(K.applyMove(s, "UR9-"), K.applyMove(s, "UR3+")); +}); + +test('scrambles: WCA shape, sign ranges, deterministic per seed', () => { + const g = K.createGame({ seed: "shape", scrambleLen: 6 }); + const y = g.scramble.indexOf("y2"); + assert.equal(y, 6, "y2 must separate the two sections"); + assert.equal(g.scramble.length, 13, "6 + y2 + 6 tokens"); + const front = g.scramble.slice(0, y), back = g.scramble.slice(y + 1); + for (const section of [front, back]) { + const names = section.map((t) => K.parseMove(t).name); + assert.equal(new Set(names).size, section.length, "no repeated move type in a section"); + for (const t of section) { + const { amount } = K.parseMove(t); + const shown = Number(t.match(/(\d+)/)[1]); + assert.ok(shown >= 0 && shown <= 5, "amount " + t + " outside the WCA -5..+5 range"); + assert.equal(amount, t.endsWith("+") ? shown : (12 - shown) % 12); + } + } + const h = K.createGame({ seed: "shape", scrambleLen: 6 }); + assert.deepEqual(g.scramble, h.scramble, "same seed must replay the same scramble"); + const i = K.createGame({ seed: "shape-2", scrambleLen: 6 }); + assert.notDeepEqual(g.scramble, i.scramble); + assert.equal(K.normalizeConfig({}).scrambleLen, 6); + assert.equal(K.normalizeConfig({ scrambleLen: 99 }).scrambleLen, 9); + assert.equal(K.normalizeConfig({ scrambleLen: 0 }).scrambleLen, 1); + assert.equal(K.clockStamp(K.normalizeConfig({})), K.clockStamp(K.normalizeConfig({}))); + assert.notEqual(K.clockStamp(K.normalizeConfig({ seed: "a" })), + K.clockStamp(K.normalizeConfig({ seed: "b" }))); + assert.equal(K.createGame({ seed: "s" }).solvedStart, false); +}); + +test('solver refuses unreachable states with a named error; solved input needs nothing', () => { + const broken = randomWalkState(5).slice(); + broken[K.CORNER_PAIRS.DL[0]] = (broken[K.CORNER_PAIRS.DL[0]] + 5) % 12; + assert.throws(() => K.solve(broken), /validation|corner-pair/, + "pair-broken state must refuse"); + assert.throws(() => K.solve([1, 2, 3]), /validation|18 dial/, + "malformed state must refuse"); + const r = K.solve(SOLVED.slice()); + assert.deepEqual(r.moves, []); + assert.deepEqual(r.final, SOLVED.slice()); + /* a single wheel move closes in one token */ + const r1 = K.solve(K.applyMove(SOLVED.slice(), "UR3+")); + assert.equal(r1.verified.solved, true); + assert.deepEqual(r1.moves, ["UR3-"]); +}); + +test('exact amounts: the raw algebra solution closes the state directly', () => { + const flat = K.SOLVE_PLAN.flatMap((p) => p.names); + for (let i = 0; i < 25; i++) { + const s = randState(); + const amounts = K.solveAmounts(s); + let t = s.slice(); + for (let j = 0; j < amounts.length; j++) { + /* back-section entries count in the flipped frame; folding the y2 + pair in and out makes the raw amounts replayable in one frame */ + const name = flat[j]; + const flipped = j >= 5 && j < 10; + if (flipped) t = K.applyMove(t, "y2"); + t = K.applyVec(t, K.MOVE_VEC[name], amounts[j]); + if (flipped) t = K.applyMove(t, "y2"); + } + assert.equal(K.verifySolved(t).solved, true, "raw amounts did not close walk " + i); + } +}); diff --git a/site/tests/cube-n-core.test.mjs b/site/tests/cube-n-core.test.mjs new file mode 100644 index 0000000..f6a2a93 --- /dev/null +++ b/site/tests/cube-n-core.test.mjs @@ -0,0 +1,219 @@ +/* Tests for cube-n-core.js — the generalized N×N×N family. + The validator's laws are simulation-derived (see the core header); these + tests keep them pinned: if a law ever fails on a seeded walk, the move + algebra or a slot definition regressed. Run: node --test tests/cube-n-core.test.mjs */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(path.join(here, "..", "cube-n-core.js"), "utf8"); +/* the core is a plain IIFE attaching to globalThis — evaluate it in this + context the same way a browser does */ +new Function(src)(); +const CNC = globalThis.CubeNCore; + +test("createCore bounds and shape", () => { + assert.throws(() => CNC.createCore(1)); + assert.throws(() => CNC.createCore(22)); + assert.throws(() => CNC.createCore(3.5)); + const c2 = CNC.createCore(2); + assert.equal(c2.stateSize, 24); + assert.equal(c2.faceCount, 4); + assert.equal(c2.edgeSlots.length, 0); /* a 2×2 has no edge pieces */ + assert.equal(c2.cornerSlots.length, 8); + const c4 = CNC.createCore(4); + assert.equal(c4.stateSize, 96); + assert.equal(c4.edgeSlots.length, 24); /* 12 edge lines × 2 wings */ + assert.equal(c4.maxDepth, 2); + const c5 = CNC.createCore(5); + assert.equal(c5.edgeSlots.length, 36); /* 12 × 3 (two wings + center edge) */ + const c21 = CNC.createCore(21); + assert.equal(c21.stateSize, 6 * 441); + assert.equal(c21.edgeSlots.length, 12 * 19); +}); + +test("facelet numbering matches cube-core at n=3 (URFDLB)", () => { + /* lattice coords for n=3 are {-2, 0, 2}; anchors are cube-core's own + values (probed, not guessed): U(2,2,2)=8, R=9, F=20, D(2,-2,-2)=35, + L(-2,2,-2)=36, B(-2,2,-2)=47 */ + const c = CNC.createCore(3); + assert.equal(c.faceletAt("U", 2, 2, 2), 8); + assert.equal(c.faceletAt("R", 2, 2, 2), 9); + assert.equal(c.faceletAt("F", 2, 2, 2), 20); + assert.equal(c.faceletAt("D", 2, -2, -2), 35); + assert.equal(c.faceletAt("L", -2, 2, -2), 36); + assert.equal(c.faceletAt("B", -2, 2, -2), 47); + /* the whole facelet space is a bijection: 54 distinct indices */ + const seen = new Set(); + for (const [x, y, z] of c.CUBIE_COORDS) { + for (const f of CNC.FACES) { + const nv = CNC.NORMAL[f]; + if (nv[0] * x + nv[1] * y + nv[2] * z !== 2) continue; + const idx = c.faceletAt(f, x, y, z); + assert.ok(idx >= 0 && idx < 54, "idx in range"); + assert.ok(!seen.has(idx), "duplicate facelet " + idx); + seen.add(idx); + } + } + assert.equal(seen.size, 54); +}); + +test("solved state validates and verifies", () => { + for (const n of [2, 3, 4, 5, 7, 21]) { + const c = CNC.createCore(n); + const v = c.validateState(c.solvedState); + assert.deepEqual(v.issues, [], "n=" + n); + assert.equal(v.legal, true); + assert.equal(c.verifySolved(c.solvedState).solved, true); + } +}); + +test("seeded random walks keep every validator law (n = 2..8)", () => { + /* the simulation-derived laws: color counts, piece multiplicity, + corner-twist sum, edge-flip sum. Any violation here means the move + algebra or slot definitions regressed. */ + for (const n of [2, 3, 4, 5, 6, 7, 8]) { + const c = CNC.createCore(n); + let state = c.solvedState.slice(); + for (let i = 0; i < 250; i++) { + const toks = c.makeScramble(1000 + i, 1); + state = c.applyAlg(state, toks); + const v = c.validateState(state); + assert.deepEqual(v.issues, [], "n=" + n + " step " + i + " after " + toks.join(" ") + + ": " + v.issues.map((x) => x.check).join(",")); + } + } +}); + +test("moves compose and invert back to solved", () => { + for (const n of [2, 4, 5, 11]) { + const c = CNC.createCore(n); + for (const tok of c.MOVE_KEYS) { + const s1 = c.applyMove(c.solvedState, tok); + if (c.verifySolved(s1).solved) continue; /* a full-cube token cannot exist, but stay safe */ + const back = c.applyMove(s1, c.invertToken(tok)); + assert.equal(c.verifySolved(back).solved, true, "n=" + n + " token " + tok); + } + } +}); + +test("wide tokens turn their full band (3Uw on 7×7 reaches depth 3)", () => { + /* n=7: maxDepth = 3, so 3Uw is legal here (it was not on 5×5). Layers + count inward from U: depth 1 = y=6, depth 2 = y=4, depth 3 = y=2; + the fixed center (y=0) never moves. An R-face sticker at y=2 sits in + band 3: Uw (band 1-2) must leave it, 3Uw must move it. */ + const c = CNC.createCore(7); + const wing = c.faceletAt("R", 6, 2, 0); + const center = c.faceletAt("U", 0, 6, 0); + const afterUw = c.applyMove(c.solvedState, "Uw"); + assert.equal(afterUw[wing], "R", "Uw must not reach a depth-3 sticker"); + assert.equal(afterUw[center], "U", "Uw must not move the fixed center"); + const after3 = c.applyMove(c.solvedState, "3Uw"); + assert.notEqual(after3[wing], "R", "3Uw must turn the depth-3 band"); + assert.equal(after3[center], "U", "no U token may move the fixed center"); + assert.throws(() => c.applyMove(c.solvedState, "4Uw"), /unknown move/, + "band deeper than maxDepth is refused"); +}); + +test("scrambles are deterministic per seed and produce legal states", () => { + for (const n of [2, 4, 5, 9]) { + const c = CNC.createCore(n); + const a = c.makeScramble(77, 20); + const b = c.makeScramble(77, 20); + const d = c.makeScramble(78, 20); + assert.deepEqual(a, b); + assert.notDeepEqual(a, d); + const g = c.createGame({ seed: "abc", scrambleLen: 20 }); + assert.equal(c.validateState(g.state).legal, true); + assert.equal(c.verifySolved(g.state).solved, false); + assert.equal(g.scramble.length, 20); + } +}); + +test("createGame is reproducible for the same string seed", () => { + const c = CNC.createCore(4); + const g1 = c.createGame({ seed: "42", scrambleLen: 10 }); + const g2 = c.createGame({ seed: "42", scrambleLen: 10 }); + assert.deepEqual(g1.state, g2.state); + assert.deepEqual(g1.scramble, g2.scramble); +}); + +test("illegal states are refused with named reasons", () => { + const c = CNC.createCore(4); + /* wrong color count: swap one facelet's color */ + const bad = c.solvedState.slice(); + bad[0] = "L"; + const v = c.validateState(bad); + assert.equal(v.legal, false); + assert.ok(v.issues.some((i) => i.check === "color-count")); + /* wrong shape */ + assert.equal(c.validateState([1, 2, 3]).legal, false); + /* a single swapped corner pair breaks piece multiplicity on a solved + board? no — swapping two corners keeps counts; but it breaks the twist + law only if twisted… a pure corner swap on a solved cube keeps every + law we pin (parity is deliberately unpinned). So instead: give one + corner slot a duplicate of another corner's colors. */ + const dup = c.solvedState.slice(); + const a = c.cornerSlots[0].f, b = c.cornerSlots[1].f; + for (let k = 0; k < 3; k++) dup[a[k]] = c.solvedState[b[k]]; + const vd = c.validateState(dup); + assert.equal(vd.legal, false); + assert.ok(vd.issues.some((i) => i.check === "piece-multiplicity" || i.check === "color-count")); +}); + +test("2×2 solver: 1000 seeded scrambles all solve, every step pre-verified", () => { + const c = CNC.createCore(2); + let solved = 0; + let maxSteps = 0; + let maxMoves = 0; + for (let seed = 1; seed <= 1000; seed++) { + const g = c.createGame({ seed: String(seed), scrambleLen: 12 }); + const plan = c.solve(g.state); + assert.ok(plan, "seed " + seed + " returned null"); + assert.equal(plan.refused, false, "seed " + seed + " refused: " + (plan.reason || "")); + /* replay the plan independently and confirm it lands on solved */ + let s = g.state; + for (const step of plan.steps) { + for (const t of step.moves) s = c.applyMove(s, t); + } + assert.equal(c.verifySolved(s).solved, true, "seed " + seed + " plan did not solve"); + assert.equal(c.validateState(s).legal, true, "seed " + seed + " plan left an illegal state"); + solved++; + maxSteps = Math.max(maxSteps, plan.steps.length); + maxMoves = Math.max(maxMoves, plan.moves.length); + } + assert.equal(solved, 1000); + assert.ok(maxSteps <= 12, "step count exploded: " + maxSteps); + assert.ok(maxMoves <= 160, "move count exploded: " + maxMoves); +}); + +test("2×2 solver: accepts an engine chooser for genuine ties", () => { + const c = CNC.createCore(2); + const g = c.createGame({ seed: "7", scrambleLen: 12 }); + let calls = 0; + const plan = c.solve(g.state, () => { calls++; return 0; }); + assert.equal(plan.refused, false); + /* ties exist on most runs; the chooser must have been consulted at least + once across the phases */ + assert.ok(calls >= 1, "chooser never consulted"); +}); + +test("2×2 solver refuses an illegal state", () => { + const c = CNC.createCore(2); + const bad = c.solvedState.slice(); + bad[0] = "L"; + const plan = c.solve(bad); + assert.equal(plan.refused, true); + assert.match(plan.reason, /invalid state/); +}); + +test("no solver hook is exposed for sizes the core does not coach", () => { + for (const n of [3, 4, 5, 21]) { + const c = CNC.createCore(n); + assert.equal(c.solverAvailable, false); + assert.equal(c.solve, undefined); + } +}); diff --git a/site/toc.js b/site/toc.js index 866dbdf..0877a76 100644 --- a/site/toc.js +++ b/site/toc.js @@ -26,14 +26,16 @@ if (!main) return []; const out = []; for (const sec of main.querySelectorAll("section[id]")) { - const head = sec.querySelector("h2"); - if (!head) continue; /* label priority: explicit override > the section's kicker line - (short by design) > the heading itself (often a full sentence) */ + (short by design) > the heading itself (often a full sentence). + Game pages carry a single h2-less section, so the heading is + optional — the explicit label or the section id carries it. */ const kicker = sec.querySelector(".kicker"); + const head = sec.querySelector("h2"); let label = sec.getAttribute("data-toc-label") || (kicker && kicker.textContent.trim()) - || head.textContent.trim(); + || (head && head.textContent.trim()) + || sec.id.replace(/[-_]/g, " "); if (label.length > 24) label = label.slice(0, 24).trimEnd() + "…"; out.push({ id: sec.id, label }); } @@ -42,7 +44,10 @@ function build() { const secs = sections(); - if (secs.length < 2) return; + /* every page gets the rail, even single-section game pages: the entry + plus the quick links and progress hairline keep navigation consistent + site-wide (a page with no main sections gets nothing). */ + if (secs.length < 1) return; const rail = document.createElement("nav"); rail.className = "toc-rail";