summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ci/.gitignore2
-rwxr-xr-x.ci/ci.cjs5047
-rw-r--r--.ci/ci.json3
-rw-r--r--.ci/ci.ts41
-rw-r--r--.ci/package-lock.json920
-rw-r--r--.ci/package.json18
-rw-r--r--.ci/tsconfig.json28
-rw-r--r--.dockerignore6
-rw-r--r--.gitignore6
-rw-r--r--Dockerfile19
-rw-r--r--LICENSE21
-rw-r--r--build.gradle.kts7
-rw-r--r--core/build.gradle.kts28
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt13
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt69
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt57
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt108
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt139
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt307
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt138
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt58
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt104
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt58
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt81
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt78
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt50
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt37
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt19
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt60
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt128
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt102
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt91
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt14
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt95
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt39
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt26
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt32
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt166
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt36
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt61
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt80
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt20
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt14
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt55
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt76
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt54
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt65
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt46
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt72
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt264
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt6
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt21
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt287
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt17
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt50
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt96
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt55
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt103
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt40
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt118
-rw-r--r--desktop/build.gradle.kts30
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt80
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt147
-rw-r--r--gradle.properties8
-rw-r--r--gradle/libs.versions.toml27
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 49154 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties9
-rwxr-xr-xgradlew248
-rw-r--r--gradlew.bat112
-rw-r--r--settings.gradle.kts18
-rw-r--r--ui/build.gradle.kts26
-rw-r--r--ui/src/commonMain/composeResources/drawable/penguin_sheet.pngbin0 -> 7050 bytes
-rw-r--r--ui/src/commonMain/composeResources/font/silkscreen.ttfbin0 -> 32220 bytes
-rw-r--r--ui/src/commonMain/composeResources/font/silkscreen_bold.ttfbin0 -> 30632 bytes
-rw-r--r--ui/src/commonMain/composeResources/font/vt323.ttfbin0 -> 153116 bytes
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt70
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt305
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt112
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt180
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt176
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt124
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt225
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt75
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt323
84 files changed, 12146 insertions, 0 deletions
diff --git a/.ci/.gitignore b/.ci/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/.ci/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/.ci/ci.cjs b/.ci/ci.cjs
new file mode 100755
index 0000000..f7a5dec
--- /dev/null
+++ b/.ci/ci.cjs
@@ -0,0 +1,5047 @@
+#!/usr/bin/env node
+"use strict";
+var __create = Object.create;
+var __defProp = Object.defineProperty;
+var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __getProtoOf = Object.getPrototypeOf;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __commonJS = (cb, mod) => function __require() {
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
+};
+var __copyProps = (to, from, except, desc) => {
+ if (from && typeof from === "object" || typeof from === "function") {
+ for (let key of __getOwnPropNames(from))
+ if (!__hasOwnProp.call(to, key) && key !== except)
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+ }
+ return to;
+};
+var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
+ // If the importer is in node compatibility mode or this is not an ESM
+ // file that has been converted to a CommonJS file using a Babel-
+ // compatible transform (i.e. "__esModule" has not been set), then set
+ // "default" to the CommonJS "module.exports" for node compatibility.
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
+ mod
+));
+
+// node_modules/@emprespresso/pengueno/dist/leftpadesque/prepend.js
+var require_prepend = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/leftpadesque/prepend.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.prependWith = void 0;
+ var prependWith = (arr, prep) => Array(arr.length * 2).fill(0).map((_, i) => i % 2 === 0).map((isPrep, i) => isPrep ? prep : arr[Math.floor(i / 2)]);
+ exports2.prependWith = prependWith;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/leftpadesque/debug.js
+var require_debug = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/leftpadesque/debug.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.isDebug = exports2.isProd = void 0;
+ var _hasEnv = true;
+ var _env = _hasEnv && (process.env.ENVIRONMENT ?? "").toLowerCase().includes("prod") ? "production" : "development";
+ var isProd = () => _env === "production";
+ exports2.isProd = isProd;
+ var _debug = !(0, exports2.isProd)() || _hasEnv && ["y", "t"].some((process.env.DEBUG ?? "").toLowerCase().startsWith);
+ var isDebug = () => _debug;
+ exports2.isDebug = isDebug;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/leftpadesque/memoize.js
+var require_memoize = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/leftpadesque/memoize.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.memoize = void 0;
+ var memoize = (fn) => {
+ const cache = /* @__PURE__ */ new Map();
+ return (...args) => {
+ const key = JSON.stringify(args);
+ if (cache.has(key)) {
+ return cache.get(key);
+ }
+ const res = fn(...args);
+ cache.set(key, res);
+ return res;
+ };
+ };
+ exports2.memoize = memoize;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/leftpadesque/index.js
+var require_leftpadesque = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/leftpadesque/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_prepend(), exports2);
+ __exportStar(require_debug(), exports2);
+ __exportStar(require_memoize(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/misc.js
+var require_misc = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/misc.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/object.js
+var require_object = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/object.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.isObject = void 0;
+ var isObject3 = (o) => typeof o === "object" && !Array.isArray(o) && !!o;
+ exports2.isObject = isObject3;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/tagged.js
+var require_tagged = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/tagged.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.isTagged = void 0;
+ var index_1 = require_types();
+ var isTagged = (o, tag) => !!((0, index_1.isObject)(o) && "_tag" in o && o._tag === tag);
+ exports2.isTagged = isTagged;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/fn/callable.js
+var require_callable = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/fn/callable.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/fn/optional.js
+var require_optional = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/fn/optional.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.Optional = exports2.IOptionalEmptyError = exports2.isOptional = exports2.IOptionalTag = void 0;
+ var pengueno_1 = require_dist2();
+ exports2.IOptionalTag = "IOptional";
+ var isOptional = (o) => (0, pengueno_1.isTagged)(o, exports2.IOptionalTag);
+ exports2.isOptional = isOptional;
+ var IOptionalEmptyError = class extends Error {
+ };
+ exports2.IOptionalEmptyError = IOptionalEmptyError;
+ var OSomeTag = "O.Some";
+ var ONoneTag = "O.None";
+ var isNone = (o) => (0, pengueno_1.isTagged)(o, ONoneTag);
+ var isSome = (o) => (0, pengueno_1.isTagged)(o, OSomeTag);
+ var _Tagged = class {
+ _tag;
+ constructor(_tag = exports2.IOptionalTag) {
+ this._tag = _tag;
+ }
+ };
+ var Optional = class _Optional extends _Tagged {
+ self;
+ constructor(self) {
+ super();
+ this.self = self;
+ }
+ move(t) {
+ return this.map(() => t);
+ }
+ orSome(supplier) {
+ if (isNone(this.self))
+ return _Optional.from(supplier());
+ return this;
+ }
+ get() {
+ if (isNone(this.self))
+ throw new IOptionalEmptyError("called get() on None optional");
+ return this.self.value;
+ }
+ filter(mapper) {
+ if (isNone(this.self) || !mapper(this.self.value))
+ return _Optional.none();
+ return _Optional.some(this.self.value);
+ }
+ map(mapper) {
+ if (isNone(this.self))
+ return _Optional.none();
+ return _Optional.from(mapper(this.self.value));
+ }
+ flatMap(mapper) {
+ if (isNone(this.self))
+ return _Optional.none();
+ return _Optional.from(mapper(this.self.value)).orSome(() => _Optional.none()).get();
+ }
+ present() {
+ return isSome(this.self);
+ }
+ *[Symbol.iterator]() {
+ if (isSome(this.self))
+ yield this.self.value;
+ }
+ static some(value) {
+ return new _Optional({ value, _tag: OSomeTag });
+ }
+ static _none = new _Optional({ _tag: ONoneTag });
+ static none() {
+ return this._none;
+ }
+ static from(value) {
+ if (value === null || value === void 0)
+ return _Optional.none();
+ return _Optional.some(value);
+ }
+ };
+ exports2.Optional = Optional;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/fn/either.js
+var require_either = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/fn/either.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.Either = exports2.isRight = exports2.isLeft = exports2.isEither = exports2.IEitherTag = void 0;
+ var pengueno_1 = require_dist2();
+ exports2.IEitherTag = "IEither";
+ var isEither = (o) => (0, pengueno_1.isTagged)(o, exports2.IEitherTag);
+ exports2.isEither = isEither;
+ var ELeftTag = "E.Left";
+ var isLeft = (o) => (0, pengueno_1.isTagged)(o, ELeftTag);
+ exports2.isLeft = isLeft;
+ var ERightTag = "E.Right";
+ var isRight = (o) => (0, pengueno_1.isTagged)(o, ERightTag);
+ exports2.isRight = isRight;
+ var _Tagged = class {
+ _tag;
+ constructor(_tag = exports2.IEitherTag) {
+ this._tag = _tag;
+ }
+ };
+ var Either2 = class _Either extends _Tagged {
+ self;
+ constructor(self) {
+ super();
+ this.self = self;
+ }
+ moveRight(t) {
+ return this.mapRight(() => t);
+ }
+ mapBoth(errBranch, okBranch) {
+ if ((0, exports2.isLeft)(this.self))
+ return _Either.left(errBranch(this.self.err));
+ return _Either.right(okBranch(this.self.ok));
+ }
+ mapRight(mapper) {
+ if ((0, exports2.isRight)(this.self))
+ return _Either.right(mapper(this.self.ok));
+ return _Either.left(this.self.err);
+ }
+ mapLeft(mapper) {
+ if ((0, exports2.isLeft)(this.self))
+ return _Either.left(mapper(this.self.err));
+ return _Either.right(this.self.ok);
+ }
+ flatMap(mapper) {
+ if ((0, exports2.isRight)(this.self))
+ return mapper(this.self.ok);
+ return _Either.left(this.self.err);
+ }
+ filter(mapper) {
+ if ((0, exports2.isLeft)(this.self))
+ return _Either.left(this.self.err);
+ return _Either.fromFailable(() => this.right().filter(mapper).get());
+ }
+ async flatMapAsync(mapper) {
+ if ((0, exports2.isLeft)(this.self))
+ return Promise.resolve(_Either.left(this.self.err));
+ return await mapper(this.self.ok).catch((err) => _Either.left(err));
+ }
+ fold(leftFolder, rightFolder) {
+ if ((0, exports2.isLeft)(this.self))
+ return leftFolder(this.self.err);
+ return rightFolder(this.self.ok);
+ }
+ left() {
+ if ((0, exports2.isLeft)(this.self))
+ return pengueno_1.Optional.from(this.self.err);
+ return pengueno_1.Optional.none();
+ }
+ right() {
+ if ((0, exports2.isRight)(this.self))
+ return pengueno_1.Optional.from(this.self.ok);
+ return pengueno_1.Optional.none();
+ }
+ joinRight(other, mapper) {
+ return this.flatMap((t) => other.mapRight((o) => mapper(o, t)));
+ }
+ joinRightAsync(other, mapper) {
+ return this.flatMapAsync(async (t) => {
+ const o = typeof other === "function" ? other() : other;
+ return await o.then((other2) => other2.mapRight((o2) => mapper(o2, t)));
+ });
+ }
+ swap() {
+ if ((0, exports2.isRight)(this.self))
+ return _Either.left(this.self.ok);
+ return _Either.right(this.self.err);
+ }
+ static left(e) {
+ return new _Either({ err: e, _tag: ELeftTag });
+ }
+ static right(t) {
+ return new _Either({ ok: t, _tag: ERightTag });
+ }
+ static fromFailable(s) {
+ try {
+ return _Either.right(s());
+ } catch (e) {
+ return _Either.left(e);
+ }
+ }
+ static async fromFailableAsync(s) {
+ return await (typeof s === "function" ? s() : s).then((t) => _Either.right(t)).catch((e) => _Either.left(e));
+ }
+ static async retrying(s, attempts = 3, interval = (attempt) => _Either.attemptWait(attempt)) {
+ let result = _Either.right(new Error("No attempts made"));
+ for (let attempt = 0; attempt < attempts && result.right().present(); attempt++) {
+ await interval(attempt);
+ const currentAttempt = await s().then((s2) => s2.swap());
+ result = await result.joinRightAsync(() => Promise.resolve(currentAttempt), (res, _prevError) => res);
+ }
+ return result.swap();
+ }
+ static attemptWait(attempt, backoffFactor = 500, jitter = 300, exponent = 1.3) {
+ if (attempt === 0) {
+ return Promise.resolve();
+ }
+ const wait = Math.pow(exponent, attempt) * backoffFactor + jitter * Math.random() * Math.pow(exponent, attempt);
+ return new Promise((res) => setTimeout(res, wait));
+ }
+ };
+ exports2.Either = Either2;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/fn/index.js
+var require_fn = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/fn/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_callable(), exports2);
+ __exportStar(require_optional(), exports2);
+ __exportStar(require_either(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/collections/cons.js
+var require_cons = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/collections/cons.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.ListZipper = exports2.Cons = void 0;
+ var pengueno_1 = require_dist2();
+ var Cons = class _Cons {
+ value;
+ next;
+ constructor(value, next = pengueno_1.Optional.none()) {
+ this.value = value;
+ this.next = next;
+ }
+ before(head) {
+ return new _Cons(this.value, head);
+ }
+ replace(_value) {
+ return new _Cons(_value, this.next);
+ }
+ *[Symbol.iterator]() {
+ for (let cur = pengueno_1.Optional.some(this); cur.present(); cur = cur.flatMap((cur2) => cur2.next)) {
+ yield cur.get().value;
+ }
+ }
+ static addOnto(items, tail) {
+ return Array.from(items).reverse().reduce((cons, value) => pengueno_1.Optional.from(new _Cons(value, cons)), tail);
+ }
+ static from(items) {
+ return _Cons.addOnto(items, pengueno_1.Optional.none());
+ }
+ };
+ exports2.Cons = Cons;
+ var ListZipper = class _ListZipper {
+ reversedPathToHead;
+ currentHead;
+ constructor(reversedPathToHead, currentHead) {
+ this.reversedPathToHead = reversedPathToHead;
+ this.currentHead = currentHead;
+ }
+ read() {
+ return this.currentHead.map(({ value }) => value);
+ }
+ next() {
+ return this.currentHead.map((head) => new _ListZipper(pengueno_1.Optional.some(head.before(this.reversedPathToHead)), head.next));
+ }
+ previous() {
+ return this.reversedPathToHead.map((lastVisited) => new _ListZipper(lastVisited.next, pengueno_1.Optional.some(lastVisited.before(this.currentHead))));
+ }
+ prependChunk(values) {
+ return new _ListZipper(Cons.addOnto(Array.from(values).reverse(), this.reversedPathToHead), this.currentHead);
+ }
+ prepend(value) {
+ return this.prependChunk([value]);
+ }
+ remove() {
+ const newHead = this.currentHead.flatMap((right) => right.next);
+ return new _ListZipper(this.reversedPathToHead, newHead);
+ }
+ replace(value) {
+ const newHead = this.currentHead.map((right) => right.replace(value));
+ return new _ListZipper(this.reversedPathToHead, newHead);
+ }
+ *[Symbol.iterator]() {
+ let head = this;
+ for (let prev = head.previous(); prev.present(); prev = prev.flatMap((p) => p.previous())) {
+ head = prev.get();
+ }
+ if (head.currentHead.present())
+ yield* head.currentHead.get();
+ }
+ collection() {
+ return Array.from(this);
+ }
+ static from(iterable) {
+ return new _ListZipper(pengueno_1.Optional.none(), Cons.from(iterable));
+ }
+ };
+ exports2.ListZipper = ListZipper;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/collections/jsonds.js
+var require_jsonds = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/collections/jsonds.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.JSONHashMap = exports2.JSONSet = void 0;
+ var JSONSet = class {
+ items;
+ constructor(items = /* @__PURE__ */ new Set()) {
+ this.items = items;
+ }
+ add(item) {
+ const itemJson = JSON.stringify(item, Object.keys(item).sort());
+ this.items.add(itemJson);
+ }
+ has(item) {
+ const itemJson = JSON.stringify(item, Object.keys(item).sort());
+ return this.items.has(itemJson);
+ }
+ delete(item) {
+ const itemJson = JSON.stringify(item, Object.keys(item).sort());
+ return this.items.delete(itemJson);
+ }
+ clear() {
+ this.items.clear();
+ }
+ size() {
+ return this.items.size;
+ }
+ };
+ exports2.JSONSet = JSONSet;
+ var JSONHashMap = class {
+ map;
+ constructor(map = /* @__PURE__ */ new Map()) {
+ this.map = map;
+ }
+ set(key, value) {
+ const keyJson = JSON.stringify(key, Object.keys(key).sort());
+ this.map.set(keyJson, value);
+ }
+ get(key) {
+ const keyJson = JSON.stringify(key, Object.keys(key).sort());
+ return this.map.get(keyJson);
+ }
+ has(key) {
+ const keyJson = JSON.stringify(key, Object.keys(key).sort());
+ return this.map.has(keyJson);
+ }
+ keys() {
+ return Array.from(this.map.keys()).map((x) => JSON.parse(x));
+ }
+ delete(key) {
+ const keyJson = JSON.stringify(key, Object.keys(key).sort());
+ return this.map.delete(keyJson);
+ }
+ clear() {
+ this.map.clear();
+ }
+ size() {
+ return this.map.size;
+ }
+ };
+ exports2.JSONHashMap = JSONHashMap;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/collections/index.js
+var require_collections = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/collections/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_cons(), exports2);
+ __exportStar(require_jsonds(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/types/index.js
+var require_types = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/types/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_misc(), exports2);
+ __exportStar(require_object(), exports2);
+ __exportStar(require_tagged(), exports2);
+ __exportStar(require_fn(), exports2);
+ __exportStar(require_collections(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/itrace.js
+var require_itrace = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/itrace.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.TraceableImpl = void 0;
+ var TraceableImpl = class _TraceableImpl {
+ item;
+ trace;
+ constructor(item, trace) {
+ this.item = item;
+ this.trace = trace;
+ }
+ map(mapper) {
+ const result = mapper(this);
+ return new _TraceableImpl(result, this.trace);
+ }
+ coExtend(mapper) {
+ const results = mapper(this);
+ return Array.from(results).map((result) => this.move(result));
+ }
+ flatMap(mapper) {
+ return mapper(this);
+ }
+ flatMapAsync(mapper) {
+ return new _TraceableImpl(mapper(this).then((t) => t.get()), this.trace);
+ }
+ traceScope(mapper) {
+ return new _TraceableImpl(this.get(), this.trace.traceScope(mapper(this)));
+ }
+ peek(peek) {
+ peek(this);
+ return this;
+ }
+ move(t) {
+ return this.map(() => t);
+ }
+ bimap(mapper) {
+ const { item, trace: _trace } = mapper(this);
+ return this.move(item).traceScope(() => _trace);
+ }
+ get() {
+ return this.item;
+ }
+ };
+ exports2.TraceableImpl = TraceableImpl;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/metric/emittable.js
+var require_emittable = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/metric/emittable.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.EmittableMetric = void 0;
+ var index_1 = require_metric2();
+ var EmittableMetric = class {
+ name;
+ unit;
+ constructor(name, unit) {
+ this.name = name;
+ this.unit = unit;
+ }
+ withValue(value) {
+ return {
+ name: this.name,
+ unit: this.unit,
+ emissionTimestamp: Date.now(),
+ value,
+ _tag: index_1.MetricValueTag
+ };
+ }
+ };
+ exports2.EmittableMetric = EmittableMetric;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/metric/metric.js
+var require_metric = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/metric/metric.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.ResultMetric = exports2.Metric = void 0;
+ var index_1 = require_metric2();
+ var _Tagged = class {
+ _tag;
+ constructor(_tag = index_1.IMetricTag) {
+ this._tag = _tag;
+ }
+ };
+ var Metric = class _Metric extends _Tagged {
+ name;
+ parent;
+ count;
+ time;
+ static DELIM = ".";
+ constructor(name, parent = void 0, count = new index_1.EmittableMetric(_Metric.join(name, "count"), index_1.Unit.COUNT), time = new index_1.EmittableMetric(_Metric.join(name, "time"), index_1.Unit.MILLISECONDS)) {
+ super();
+ this.name = name;
+ this.parent = parent;
+ this.count = count;
+ this.time = time;
+ }
+ child(_name) {
+ const childName = _Metric.join(this.name, _name);
+ return new _Metric(childName, this);
+ }
+ asResult() {
+ return ResultMetric.from(this);
+ }
+ static join(...name) {
+ return name.join(_Metric.DELIM);
+ }
+ static fromName(name) {
+ return new _Metric(name);
+ }
+ };
+ exports2.Metric = Metric;
+ var ResultMetric = class _ResultMetric extends Metric {
+ name;
+ parent;
+ failure;
+ success;
+ warn;
+ constructor(name, parent = void 0, failure, success, warn) {
+ super(name, parent);
+ this.name = name;
+ this.parent = parent;
+ this.failure = failure;
+ this.success = success;
+ this.warn = warn;
+ }
+ static from(metric) {
+ const failure = metric.child("failure");
+ const success = metric.child("success");
+ const warn = metric.child("warn");
+ return new _ResultMetric(metric.name, metric.parent, failure, success, warn);
+ }
+ };
+ exports2.ResultMetric = ResultMetric;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/metric/trace.js
+var require_trace = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/metric/trace.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.MetricsTrace = exports2.isMetricsTraceSupplier = void 0;
+ var pengueno_1 = require_dist2();
+ var isMetricsTraceSupplier = (t) => (0, pengueno_1.isMetricValue)(t) || (0, pengueno_1.isIMetric)(t) || Array.isArray(t) && t.every((_m) => (0, pengueno_1.isMetricValue)(_m) || (0, pengueno_1.isIMetric)(_m));
+ exports2.isMetricsTraceSupplier = isMetricsTraceSupplier;
+ var MetricsTrace = class _MetricsTrace {
+ metricConsumer;
+ activeTraces;
+ completedTraces;
+ constructor(metricConsumer, activeTraces = /* @__PURE__ */ new Map(), completedTraces = /* @__PURE__ */ new Set()) {
+ this.metricConsumer = metricConsumer;
+ this.activeTraces = activeTraces;
+ this.completedTraces = completedTraces;
+ }
+ traceScope(trace) {
+ const now = Date.now();
+ const metricsToTrace = (Array.isArray(trace) ? trace : [trace]).filter(pengueno_1.isIMetric);
+ const initialTraces = new Map(metricsToTrace.map((metric) => [metric.name, now]));
+ return new _MetricsTrace(this.metricConsumer, initialTraces, this.completedTraces);
+ }
+ trace(metrics) {
+ if (!metrics || typeof metrics === "string") {
+ return this;
+ }
+ const now = Date.now();
+ const allMetrics = Array.isArray(metrics) ? metrics : [metrics];
+ const valuesToEmit = allMetrics.filter(pengueno_1.isMetricValue);
+ const traceableMetrics = allMetrics.filter(pengueno_1.isIMetric);
+ const metricsToStart = traceableMetrics.filter((m) => !this.activeTraces.has(m.name));
+ const metricsToEnd = traceableMetrics.filter((m) => this.activeTraces.has(m.name) && !this.completedTraces.has(m.name));
+ const endedMetricValues = metricsToEnd.flatMap((metric) => [
+ metric.count.withValue(1),
+ metric.time.withValue(now - this.activeTraces.get(metric.name))
+ ]);
+ const parentBasedMetrics = metricsToStart.filter((metric) => {
+ const parent = metric.parent;
+ return parent && this.activeTraces.has(parent.name);
+ });
+ const parentBasedValues = parentBasedMetrics.flatMap((metric) => {
+ const parentStart = this.activeTraces.get(metric.parent.name);
+ return [metric.count.withValue(1), metric.time.withValue(now - parentStart)];
+ });
+ const allMetricsToEmit = [...valuesToEmit, ...endedMetricValues, ...parentBasedValues];
+ if (allMetricsToEmit.length > 0) {
+ this.metricConsumer(allMetricsToEmit);
+ }
+ const nextActiveTraces = new Map([
+ ...this.activeTraces,
+ ...metricsToStart.map((m) => [m.name, now])
+ ]);
+ const nextCompletedTraces = /* @__PURE__ */ new Set([
+ ...this.completedTraces,
+ ...metricsToEnd.map((m) => m.name),
+ ...parentBasedMetrics.map((m) => m.name)
+ ]);
+ return new _MetricsTrace(this.metricConsumer, nextActiveTraces, nextCompletedTraces);
+ }
+ };
+ exports2.MetricsTrace = MetricsTrace;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/metric/index.js
+var require_metric2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/metric/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.isIMetric = exports2.IMetricTag = exports2.isMetricValue = exports2.MetricValueTag = exports2.Unit = void 0;
+ var pengueno_1 = require_dist2();
+ var Unit;
+ (function(Unit2) {
+ Unit2["COUNT"] = "COUNT";
+ Unit2["MILLISECONDS"] = "MILLISECONDS";
+ })(Unit || (exports2.Unit = Unit = {}));
+ exports2.MetricValueTag = "MetricValue";
+ var isMetricValue = (t) => (0, pengueno_1.isTagged)(t, exports2.MetricValueTag);
+ exports2.isMetricValue = isMetricValue;
+ exports2.IMetricTag = "IMetric";
+ var isIMetric = (t) => (0, pengueno_1.isTagged)(t, exports2.IMetricTag);
+ exports2.isIMetric = isIMetric;
+ __exportStar(require_emittable(), exports2);
+ __exportStar(require_metric(), exports2);
+ __exportStar(require_trace(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/ansi.js
+var require_ansi = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/ansi.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.ANSI = void 0;
+ exports2.ANSI = {
+ RESET: "\x1B[0m",
+ BOLD: "\x1B[1m",
+ DIM: "\x1B[2m",
+ RED: "\x1B[31m",
+ GREEN: "\x1B[32m",
+ YELLOW: "\x1B[33m",
+ BLUE: "\x1B[34m",
+ MAGENTA: "\x1B[35m",
+ CYAN: "\x1B[36m",
+ WHITE: "\x1B[37m",
+ BRIGHT_RED: "\x1B[91m",
+ BRIGHT_YELLOW: "\x1B[93m",
+ GRAY: "\x1B[90m"
+ };
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/level.js
+var require_level = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/level.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.isLogLevel = exports2.logLevelOrder = exports2.LogLevel = void 0;
+ var LogLevel;
+ (function(LogLevel2) {
+ LogLevel2["UNKNOWN"] = "UNKNOWN";
+ LogLevel2["INFO"] = "INFO";
+ LogLevel2["WARN"] = "WARN";
+ LogLevel2["DEBUG"] = "DEBUG";
+ LogLevel2["ERROR"] = "ERROR";
+ LogLevel2["SYS"] = "SYS";
+ })(LogLevel || (exports2.LogLevel = LogLevel = {}));
+ exports2.logLevelOrder = [
+ LogLevel.DEBUG,
+ LogLevel.INFO,
+ LogLevel.WARN,
+ LogLevel.ERROR,
+ LogLevel.SYS
+ ];
+ var isLogLevel = (l) => typeof l === "string" && exports2.logLevelOrder.some((level) => level === l);
+ exports2.isLogLevel = isLogLevel;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/logger.js
+var require_logger = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/logger.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/pretty_json_console.js
+var require_pretty_json_console = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/pretty_json_console.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.PrettyJsonConsoleLogger = void 0;
+ var index_1 = require_log();
+ var PrettyJsonConsoleLogger = class {
+ log(level, ...trace) {
+ const message = JSON.stringify({
+ level,
+ trace
+ }, null, 4);
+ const styled = `${this.getStyle(level)}${message}${index_1.ANSI.RESET}
+`;
+ this.getStream(level)(styled);
+ }
+ getStream(level) {
+ if (level === index_1.LogLevel.ERROR) {
+ return console.error;
+ }
+ return console.log;
+ }
+ getStyle(level) {
+ switch (level) {
+ case index_1.LogLevel.UNKNOWN:
+ case index_1.LogLevel.INFO:
+ return `${index_1.ANSI.MAGENTA}`;
+ case index_1.LogLevel.DEBUG:
+ return `${index_1.ANSI.CYAN}`;
+ case index_1.LogLevel.WARN:
+ return `${index_1.ANSI.BRIGHT_YELLOW}`;
+ case index_1.LogLevel.ERROR:
+ return `${index_1.ANSI.BRIGHT_RED}`;
+ case index_1.LogLevel.SYS:
+ return `${index_1.ANSI.DIM}${index_1.ANSI.BLUE}`;
+ }
+ }
+ };
+ exports2.PrettyJsonConsoleLogger = PrettyJsonConsoleLogger;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/trace.js
+var require_trace2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/trace.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.LogTrace = void 0;
+ var pengueno_1 = require_dist2();
+ var index_1 = require_log();
+ var LogTrace = class _LogTrace {
+ logger;
+ traces;
+ defaultLevel;
+ allowedLevels;
+ constructor(logger = new index_1.PrettyJsonConsoleLogger(), traces = [defaultTrace], defaultLevel = index_1.LogLevel.INFO, allowedLevels = defaultAllowedLevelsSupplier) {
+ this.logger = logger;
+ this.traces = traces;
+ this.defaultLevel = defaultLevel;
+ this.allowedLevels = allowedLevels;
+ }
+ traceScope(trace) {
+ return new _LogTrace(this.logger, this.traces.concat(trace), this.defaultLevel, this.allowedLevels);
+ }
+ trace(trace) {
+ const { traces, level: _level } = this.foldTraces(this.traces.concat(trace));
+ if (!this.allowedLevels().has(_level))
+ return;
+ const level = _level === index_1.LogLevel.UNKNOWN ? this.defaultLevel : _level;
+ this.logger.log(level, ...traces);
+ }
+ foldTraces(_traces) {
+ const _logTraces = _traces.map((trace) => typeof trace === "function" ? trace() : trace);
+ const _level = _logTraces.filter((trace) => (0, index_1.isLogLevel)(trace)).reduce((acc, level2) => Math.max(index_1.logLevelOrder.indexOf(level2), acc), -1);
+ const level = index_1.logLevelOrder[_level] ?? index_1.LogLevel.UNKNOWN;
+ const traces = _logTraces.filter((trace) => !(0, index_1.isLogLevel)(trace)).map((trace) => {
+ if (typeof trace === "object") {
+ return `TracedException.Name = ${trace.name}, TracedException.Message = ${trace.message}, TracedException.Stack = ${trace.stack}`;
+ }
+ return trace;
+ });
+ return {
+ level,
+ traces
+ };
+ }
+ };
+ exports2.LogTrace = LogTrace;
+ var defaultTrace = () => `TimeStamp = ${(/* @__PURE__ */ new Date()).toISOString()}`;
+ var defaultAllowedLevels = (0, pengueno_1.memoize)((isDebug) => /* @__PURE__ */ new Set([
+ index_1.LogLevel.UNKNOWN,
+ ...isDebug ? [index_1.LogLevel.DEBUG] : [],
+ index_1.LogLevel.INFO,
+ index_1.LogLevel.WARN,
+ index_1.LogLevel.ERROR,
+ index_1.LogLevel.SYS
+ ]));
+ var defaultAllowedLevelsSupplier = () => defaultAllowedLevels((0, pengueno_1.isDebug)());
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/log/index.js
+var require_log = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/log/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_ansi(), exports2);
+ __exportStar(require_level(), exports2);
+ __exportStar(require_logger(), exports2);
+ __exportStar(require_pretty_json_console(), exports2);
+ __exportStar(require_trace2(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/trace.js
+var require_trace3 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/trace.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.LogMetricTraceable = exports2.LogMetricTrace = exports2.EmbeddedMetricsTraceable = exports2.LogTraceable = void 0;
+ var _1 = require_trace4();
+ var LogTraceable = class _LogTraceable extends _1.TraceableImpl {
+ static LogTrace = new _1.LogTrace();
+ static of(t) {
+ return new _LogTraceable(t, _LogTraceable.LogTrace);
+ }
+ };
+ exports2.LogTraceable = LogTraceable;
+ var getEmbeddedMetricConsumer = (logTrace) => (metrics) => {
+ if (metrics.length === 0)
+ return;
+ logTrace.traceScope(_1.LogLevel.SYS).trace(`Metrics = <metrics>${JSON.stringify(metrics)}</metrics>`);
+ };
+ var EmbeddedMetricsTraceable = class _EmbeddedMetricsTraceable extends _1.TraceableImpl {
+ static MetricsTrace = new _1.MetricsTrace(getEmbeddedMetricConsumer(LogTraceable.LogTrace));
+ static of(t, metricsTrace = _EmbeddedMetricsTraceable.MetricsTrace) {
+ return new _EmbeddedMetricsTraceable(t, metricsTrace);
+ }
+ };
+ exports2.EmbeddedMetricsTraceable = EmbeddedMetricsTraceable;
+ var LogMetricTrace = class _LogMetricTrace {
+ logTrace;
+ metricsTrace;
+ constructor(logTrace, metricsTrace) {
+ this.logTrace = logTrace;
+ this.metricsTrace = metricsTrace;
+ }
+ traceScope(trace) {
+ if ((0, _1.isMetricsTraceSupplier)(trace)) {
+ return new _LogMetricTrace(this.logTrace, this.metricsTrace.traceScope(trace));
+ }
+ return new _LogMetricTrace(this.logTrace.traceScope(trace), this.metricsTrace);
+ }
+ trace(trace) {
+ if ((0, _1.isMetricsTraceSupplier)(trace)) {
+ this.metricsTrace.trace(trace);
+ return this;
+ }
+ this.logTrace.trace(trace);
+ return this;
+ }
+ };
+ exports2.LogMetricTrace = LogMetricTrace;
+ var LogMetricTraceable = class _LogMetricTraceable extends _1.TraceableImpl {
+ static ofLogTraceable(t) {
+ const metricsTrace = new _1.MetricsTrace(getEmbeddedMetricConsumer(t.trace));
+ return new _LogMetricTraceable(t.get(), new LogMetricTrace(t.trace, metricsTrace));
+ }
+ static of(t) {
+ const logTrace = LogTraceable.of(t);
+ return _LogMetricTraceable.ofLogTraceable(logTrace);
+ }
+ };
+ exports2.LogMetricTraceable = LogMetricTraceable;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/util.js
+var require_util = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/util.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.TraceUtil = void 0;
+ var pengueno_1 = require_dist2();
+ var TraceUtil = class _TraceUtil {
+ static promiseify(mapper) {
+ return (traceablePromise) => traceablePromise.flatMapAsync(async (t) => t.move(await t.get()).map(mapper)).get();
+ }
+ static traceResultingEither(metric, warnOnFailure = false) {
+ return (t) => {
+ if (metric)
+ t.trace.trace(t.get().fold((_err) => warnOnFailure ? metric.warn : metric.failure, (_ok) => metric.success));
+ return t.traceScope((_t) => _t.get().fold((_err) => warnOnFailure ? pengueno_1.LogLevel.WARN : pengueno_1.LogLevel.ERROR, (_ok) => pengueno_1.LogLevel.INFO));
+ };
+ }
+ static withTrace(trace) {
+ return (t) => t.traceScope(() => trace);
+ }
+ static withMetricTrace(metric) {
+ return _TraceUtil.withTrace(metric);
+ }
+ static withFunctionTrace(f) {
+ return _TraceUtil.withTrace(`fn.${f.name}`);
+ }
+ static withClassTrace(c) {
+ return _TraceUtil.withTrace(`class.${c.constructor.name}`);
+ }
+ };
+ exports2.TraceUtil = TraceUtil;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/trace/index.js
+var require_trace4 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/trace/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_itrace(), exports2);
+ __exportStar(require_metric2(), exports2);
+ __exportStar(require_log(), exports2);
+ __exportStar(require_trace3(), exports2);
+ __exportStar(require_util(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/exec.js
+var require_exec = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/exec.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.getStdoutMany = exports2.getStdout = exports2.CmdMetric = void 0;
+ var pengueno_1 = require_dist2();
+ var node_child_process_1 = require("node:child_process");
+ exports2.CmdMetric = pengueno_1.Metric.fromName("Exec").asResult();
+ var getStdout = (cmd, options = { streamTraceable: [] }) => cmd.flatMap(pengueno_1.TraceUtil.withFunctionTrace(exports2.getStdout)).flatMap((tCmd) => tCmd.traceScope(() => `Command = ${tCmd.get()}`)).map((tCmd) => {
+ const cmd2 = tCmd.get();
+ const _exec = typeof cmd2 === "string" ? cmd2 : cmd2.join(" ");
+ const env = options.clearEnv ? options.env : { ...process.env, ...options.env };
+ return pengueno_1.Either.fromFailableAsync(new Promise((res, rej) => {
+ const proc = (0, node_child_process_1.exec)(_exec, { env });
+ let stdout = "";
+ proc.stdout?.on("data", (d) => {
+ const s = d.toString();
+ stdout += s;
+ if (options.streamTraceable?.includes("stdout")) {
+ tCmd.trace.trace(s);
+ }
+ });
+ const stderr = "";
+ proc.stderr?.on("data", (d) => {
+ const s = d.toString();
+ stdout += s;
+ if (options.streamTraceable?.includes("stderr")) {
+ tCmd.trace.trace(s);
+ }
+ });
+ proc.on("exit", (code) => {
+ const streams = { stdout, stderr };
+ if (code === 0) {
+ res(streams);
+ } else {
+ rej(new Error(`exited with non-zero code: ${code}. ${stderr}`));
+ }
+ });
+ }));
+ }).map(pengueno_1.TraceUtil.promiseify((tEitherStdStreams) => tEitherStdStreams.get().mapRight(({ stderr, stdout }) => {
+ if (stderr)
+ tEitherStdStreams.trace.traceScope(pengueno_1.LogLevel.DEBUG).trace(`StdErr = ${stderr}`);
+ return stdout;
+ }))).peek(pengueno_1.TraceUtil.promiseify(pengueno_1.TraceUtil.traceResultingEither(exports2.CmdMetric))).get();
+ exports2.getStdout = getStdout;
+ var getStdoutMany = (cmds, options = { streamTraceable: [] }) => cmds.coExtend((t) => t.get()).reduce(async (_result, tCmd) => {
+ const result = await _result;
+ return result.joinRightAsync(() => tCmd.map((cmd) => (0, exports2.getStdout)(cmd, options)).get(), (stdout, pre) => pre.concat(stdout));
+ }, Promise.resolve(pengueno_1.Either.right([])));
+ exports2.getStdoutMany = getStdoutMany;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/env.js
+var require_env = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/env.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.getRequiredEnvVars = exports2.getRequiredEnv = exports2.getEnv = void 0;
+ var pengueno_1 = require_dist2();
+ var getEnv = (name) => pengueno_1.Optional.from(process.env[name]);
+ exports2.getEnv = getEnv;
+ var getRequiredEnv = (name) => pengueno_1.Either.fromFailable(() => (0, exports2.getEnv)(name).get()).mapLeft(() => new Error(`environment variable "${name}" is required D:`));
+ exports2.getRequiredEnv = getRequiredEnv;
+ var getRequiredEnvVars = (vars) => {
+ const emptyEnvironment = pengueno_1.Either.right({});
+ const addTo = (env, key, val) => ({
+ ...env,
+ [key]: val
+ });
+ return vars.reduce((environment, key) => environment.joinRight((0, exports2.getRequiredEnv)(key), (value, environment2) => addTo(environment2, key, value)), emptyEnvironment);
+ };
+ exports2.getRequiredEnvVars = getRequiredEnvVars;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/validate_identifier.js
+var require_validate_identifier = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/validate_identifier.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.validateExecutionEntries = exports2.validateIdentifier = void 0;
+ var pengueno_1 = require_dist2();
+ var validateIdentifier = (token) => {
+ return /^[a-zA-Z0-9_\?\&\=\-:. \/]+$/.test(token) && !token.includes("..");
+ };
+ exports2.validateIdentifier = validateIdentifier;
+ var validateExecutionEntries = (obj) => {
+ const invalidEntries = Object.entries(obj).filter((e) => !e.every((x) => typeof x === "string" && (0, exports2.validateIdentifier)(x)));
+ if (invalidEntries.length > 0)
+ return pengueno_1.Either.left(invalidEntries);
+ return pengueno_1.Either.right(obj);
+ };
+ exports2.validateExecutionEntries = validateExecutionEntries;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/argv.js
+var require_argv = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/argv.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.argv = exports2.getArg = exports2.isArgKey = void 0;
+ var pengueno_1 = require_dist2();
+ var isArgKey = (k) => k.startsWith("--");
+ exports2.isArgKey = isArgKey;
+ var getArg = (arg, argv2, whenValue) => {
+ const argIndex = pengueno_1.Optional.from(argv2.findIndex((_argv) => (0, exports2.isArgKey)(_argv) && _argv.split("=")[0] === arg)).filter((index) => index >= 0 && index < argv2.length);
+ if (!argIndex.present()) {
+ return pengueno_1.Optional.from(whenValue.absent).map((v) => pengueno_1.Either.right(v)).orSome(() => pengueno_1.Either.left(new Error(`arg ${arg} is not present in arguments list and does not have an 'absent' value`))).get();
+ }
+ return argIndex.flatMap((idx) => pengueno_1.Optional.from(argv2.at(idx)).map((_argv) => _argv.includes("=") ? _argv.split("=")[1] : argv2.at(idx + 1))).filter((next) => !(0, exports2.isArgKey)(next)).map((next) => whenValue.present(next)).orSome(() => whenValue.unspecified).map((v) => pengueno_1.Either.right(v)).get();
+ };
+ exports2.getArg = getArg;
+ var argv = (args, handlers, argv2 = process.argv.slice(2)) => {
+ const defaultHandler = { present: (value) => value };
+ const processArg = (arg) => {
+ const handler = handlers?.[arg] ?? defaultHandler;
+ return (0, exports2.getArg)(arg, argv2, handler).mapRight((value) => [arg, value]);
+ };
+ const res = args.map(processArg).reduce((acc, current) => acc.flatMap((accValue) => current.mapRight(([key, value]) => ({
+ ...accValue,
+ [key]: value
+ }))), pengueno_1.Either.right({})).mapRight((result) => result);
+ return res;
+ };
+ exports2.argv = argv;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/signals.js
+var require_signals = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/signals.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.Signals = exports2.SigTermMetric = exports2.SigIntMetric = void 0;
+ var pengueno_1 = require_dist2();
+ exports2.SigIntMetric = pengueno_1.Metric.fromName("SigInt").asResult();
+ exports2.SigTermMetric = pengueno_1.Metric.fromName("SigTerm").asResult();
+ var Signals = class {
+ static async awaitClose(t) {
+ const success = pengueno_1.Either.right(void 0);
+ return new Promise((res) => {
+ const metricizedInterruptHandler = (metric) => (err) => t.flatMap(pengueno_1.TraceUtil.withMetricTrace(metric)).peek((_t) => _t.trace.trace("closing")).move(pengueno_1.Optional.from(err).map((e) => pengueno_1.Either.left(e)).orSome(() => success).get()).flatMap(pengueno_1.TraceUtil.traceResultingEither(metric)).map((e) => res(e.get())).peek((_t) => _t.trace.trace("finished")).get();
+ const sigintCloser = metricizedInterruptHandler(exports2.SigIntMetric);
+ const sigtermCloser = metricizedInterruptHandler(exports2.SigTermMetric);
+ process.on("SIGINT", () => t.flatMap(pengueno_1.TraceUtil.withTrace("SIGINT")).get().close(sigintCloser));
+ process.on("SIGTERM", () => t.flatMap(pengueno_1.TraceUtil.withTrace("SIGTERM")).get().close(sigtermCloser));
+ });
+ }
+ };
+ exports2.Signals = Signals;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/process/index.js
+var require_process = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/process/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_exec(), exports2);
+ __exportStar(require_env(), exports2);
+ __exportStar(require_validate_identifier(), exports2);
+ __exportStar(require_argv(), exports2);
+ __exportStar(require_signals(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/http/body.js
+var require_body = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/http/body.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/http/status.js
+var require_status = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/http/status.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.HttpStatusCodes = void 0;
+ exports2.HttpStatusCodes = {
+ 100: "Continue",
+ 101: "Switching Protocols",
+ 102: "Processing (WebDAV)",
+ 200: "OK",
+ 201: "Created",
+ 202: "Accepted",
+ 203: "Non-Authoritative Information",
+ 204: "No Content",
+ 205: "Reset Content",
+ 206: "Partial Content",
+ 207: "Multi-Status (WebDAV)",
+ 208: "Already Reported (WebDAV)",
+ 226: "IM Used",
+ 300: "Multiple Choices",
+ 301: "Moved Permanently",
+ 302: "Found",
+ 303: "See Other",
+ 304: "Not Modified",
+ 305: "Use Proxy",
+ 306: "(Unused)",
+ 307: "Temporary Redirect",
+ 308: "Permanent Redirect (experimental)",
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 402: "Payment Required",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 406: "Not Acceptable",
+ 407: "Proxy Authentication Required",
+ 408: "Request Timeout",
+ 409: "Conflict",
+ 410: "Gone",
+ 411: "Length Required",
+ 412: "Precondition Failed",
+ 413: "Request Entity Too Large",
+ 414: "Request-URI Too Long",
+ 415: "Unsupported Media Type",
+ 416: "Requested Range Not Satisfiable",
+ 417: "Expectation Failed",
+ 418: "I'm a teapot (RFC 2324)",
+ 420: "Enhance Your Calm (Twitter)",
+ 422: "Unprocessable Entity (WebDAV)",
+ 423: "Locked (WebDAV)",
+ 424: "Failed Dependency (WebDAV)",
+ 425: "Reserved for WebDAV",
+ 426: "Upgrade Required",
+ 428: "Precondition Required",
+ 429: "Too Many Requests",
+ 431: "Request Header Fields Too Large",
+ 444: "No Response (Nginx)",
+ 449: "Retry With (Microsoft)",
+ 450: "Blocked by Windows Parental Controls (Microsoft)",
+ 451: "Unavailable For Legal Reasons",
+ 499: "Client Closed Request (Nginx)",
+ 500: "Internal Server Error",
+ 501: "Not Implemented",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+ 504: "Gateway Timeout",
+ 505: "HTTP Version Not Supported",
+ 506: "Variant Also Negotiates (Experimental)",
+ 507: "Insufficient Storage (WebDAV)",
+ 508: "Loop Detected (WebDAV)",
+ 509: "Bandwidth Limit Exceeded (Apache)",
+ 510: "Not Extended",
+ 511: "Network Authentication Required",
+ 598: "Network read timeout error",
+ 599: "Network connect timeout error"
+ };
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/http/method.js
+var require_method = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/http/method.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/http/index.js
+var require_http = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/http/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_body(), exports2);
+ __exportStar(require_status(), exports2);
+ __exportStar(require_method(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/response/pengueno.js
+var require_pengueno = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/response/pengueno.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.JsonResponse = exports2.PenguenoResponse = exports2.getResponseMetrics = void 0;
+ var pengueno_1 = require_dist2();
+ var getHeaders = (req, extraHeaders) => {
+ const optHeaders = {
+ ...req.getResponseHeaders(),
+ ...extraHeaders
+ };
+ optHeaders["Content-Type"] = (optHeaders["Content-Type"] ?? "text/plain") + "; charset=utf-8";
+ return optHeaders;
+ };
+ var ResponseCodeMetrics = [0, 1, 2, 3, 4, 5].map((x) => pengueno_1.Metric.fromName(`response.${x}xx`).asResult());
+ var getResponseMetrics = (status, elapsedMs) => {
+ const index = Math.floor(status / 100);
+ return ResponseCodeMetrics.flatMap((metric, i) => pengueno_1.Optional.from(i).filter((i2) => i2 === index).map(() => [metric.count.withValue(1)]).flatMap((metricValues) => pengueno_1.Optional.from(elapsedMs).map((ms) => metricValues.concat(metric.time.withValue(ms))).orSome(() => metricValues)).orSome(() => [metric.count.withValue(0)]).get());
+ };
+ exports2.getResponseMetrics = getResponseMetrics;
+ var PenguenoResponse = class {
+ _body;
+ statusText;
+ status;
+ headers;
+ constructor(req, _body, opts) {
+ this._body = _body;
+ this.headers = getHeaders(req.get(), opts?.headers ?? {});
+ this.status = opts.status;
+ this.statusText = opts.statusText ?? pengueno_1.HttpStatusCodes[this.status];
+ req.trace.trace((0, exports2.getResponseMetrics)(opts.status, req.get().elapsedTimeMs()));
+ }
+ body() {
+ return this._body;
+ }
+ };
+ exports2.PenguenoResponse = PenguenoResponse;
+ var JsonResponse = class extends PenguenoResponse {
+ constructor(req, e, _opts) {
+ const opts = { ..._opts, headers: { ..._opts.headers, "Content-Type": "application/json" } };
+ if ((0, pengueno_1.isEither)(e)) {
+ super(req, JSON.stringify(e.fold((error) => ({ error, ok: void 0 }), (ok) => ({ ok }))), opts);
+ return;
+ }
+ super(req, JSON.stringify(Math.floor(opts.status / 100) > 4 ? { error: e } : { ok: e }), opts);
+ }
+ };
+ exports2.JsonResponse = JsonResponse;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/response/index.js
+var require_response = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/response/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_pengueno(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/request/pengueno.js
+var require_pengueno2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/request/pengueno.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.PenguenoRequest = void 0;
+ var greetings = ["hewwo :D", "hiya cutie", "boop!", "sending virtual hugs!", "stay pawsitive"];
+ var penguenoGreeting = () => greetings[Math.floor(Math.random() * greetings.length)];
+ var PenguenoRequest = class _PenguenoRequest {
+ req;
+ id;
+ at;
+ constructor(req, id, at) {
+ this.req = req;
+ this.id = id;
+ this.at = at;
+ }
+ elapsedTimeMs(after = () => Date.now()) {
+ return after() - this.at.getTime();
+ }
+ getResponseHeaders() {
+ const RequestId = this.id;
+ const RequestReceivedUnix = this.at.getTime();
+ const RequestHandleUnix = Date.now();
+ const DeltaUnix = this.elapsedTimeMs(() => RequestHandleUnix);
+ const Hai = penguenoGreeting();
+ return Object.entries({
+ RequestId,
+ RequestReceivedUnix,
+ RequestHandleUnix,
+ DeltaUnix,
+ Hai
+ }).reduce((acc, [key, val]) => ({ ...acc, [key]: val.toString() }), {});
+ }
+ static from(request) {
+ const id = crypto.randomUUID();
+ return request.bimap((tRequest) => {
+ const request2 = tRequest.get();
+ const url = new URL(request2.url);
+ const { pathname } = url;
+ const trace = `RequestId = ${id}, Method = ${request2.method}, Path = ${pathname}`;
+ return { item: new _PenguenoRequest(request2, id, /* @__PURE__ */ new Date()), trace };
+ });
+ }
+ };
+ exports2.PenguenoRequest = PenguenoRequest;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/request/index.js
+var require_request = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/request/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_pengueno2(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/activity/health.js
+var require_health = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/activity/health.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.HealthCheckActivityImpl = exports2.HealthCheckOutput = exports2.HealthCheckInput = void 0;
+ var pengueno_1 = require_dist2();
+ var HealthCheckInput;
+ (function(HealthCheckInput2) {
+ HealthCheckInput2[HealthCheckInput2["CHECK"] = 0] = "CHECK";
+ })(HealthCheckInput || (exports2.HealthCheckInput = HealthCheckInput = {}));
+ var HealthCheckOutput;
+ (function(HealthCheckOutput2) {
+ HealthCheckOutput2[HealthCheckOutput2["YAASSSLAYQUEEN"] = 0] = "YAASSSLAYQUEEN";
+ })(HealthCheckOutput || (exports2.HealthCheckOutput = HealthCheckOutput = {}));
+ var healthCheckMetric = pengueno_1.Metric.fromName("Health").asResult();
+ var HealthCheckActivityImpl = class {
+ check;
+ constructor(check) {
+ this.check = check;
+ }
+ checkHealth(req) {
+ return req.flatMap(pengueno_1.TraceUtil.withFunctionTrace(this.checkHealth)).flatMap(pengueno_1.TraceUtil.withMetricTrace(healthCheckMetric)).flatMap((r) => r.move(HealthCheckInput.CHECK).map((input) => this.check(input))).peek(pengueno_1.TraceUtil.promiseify(pengueno_1.TraceUtil.traceResultingEither(healthCheckMetric))).map(pengueno_1.TraceUtil.promiseify((h) => {
+ const { status, message } = h.get().fold(() => ({ status: 500, message: "err" }), () => ({ status: 200, message: "ok" }));
+ return new pengueno_1.JsonResponse(req, message, { status });
+ })).get();
+ }
+ };
+ exports2.HealthCheckActivityImpl = HealthCheckActivityImpl;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/activity/fourohfour.js
+var require_fourohfour = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/activity/fourohfour.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.FourOhFourActivityImpl = void 0;
+ var pengueno_1 = require_dist2();
+ var messages = [
+ "D: meow-t found! your api call ran away!",
+ "404-bidden! but like...in a cute way >:3 !",
+ ":< your data went on a paw-sible vacation!",
+ "uwu~ not found, but found our hearts instead!"
+ ];
+ var randomFourOhFour = () => messages[Math.floor(Math.random() * messages.length)];
+ var FourOhFourActivityImpl = class {
+ fourOhFour(req) {
+ return req.move(new pengueno_1.JsonResponse(req, randomFourOhFour(), { status: 404 })).map((resp) => Promise.resolve(resp.get())).get();
+ }
+ };
+ exports2.FourOhFourActivityImpl = FourOhFourActivityImpl;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/activity/index.js
+var require_activity = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/activity/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_health(), exports2);
+ __exportStar(require_fourohfour(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/filter/method.js
+var require_method2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/filter/method.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.requireMethod = void 0;
+ var pengueno_1 = require_dist2();
+ var requireMethod = (methods) => (req) => req.flatMap(pengueno_1.TraceUtil.withFunctionTrace(exports2.requireMethod)).map((t) => {
+ const { req: { method } } = t.get();
+ if (!methods.includes(method)) {
+ const msg = "that's not how you pet me (\u22DF\uFE4F\u22DE)~";
+ t.trace.traceScope(pengueno_1.LogLevel.WARN).trace(msg);
+ return pengueno_1.Either.left(new pengueno_1.PenguenoError(msg, 405));
+ }
+ return pengueno_1.Either.right(method);
+ }).get();
+ exports2.requireMethod = requireMethod;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/filter/json.js
+var require_json = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/filter/json.js"(exports2) {
+ "use strict";
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.jsonModel = void 0;
+ var pengueno_1 = require_dist2();
+ var ParseJsonMetric = pengueno_1.Metric.fromName("JsonParse").asResult();
+ var jsonModel = (jsonTransformer) => (r) => r.flatMap(pengueno_1.TraceUtil.withFunctionTrace(exports2.jsonModel)).flatMap(pengueno_1.TraceUtil.withMetricTrace(ParseJsonMetric)).map((j) => pengueno_1.Either.fromFailableAsync(j.get().req.json()).then((either) => either.mapLeft((errReason) => {
+ j.trace.traceScope(pengueno_1.LogLevel.WARN).trace(errReason);
+ return new pengueno_1.PenguenoError("seems to be invalid JSON (>//<) can you fix?", 400);
+ }))).flatMapAsync(pengueno_1.TraceUtil.promiseify(pengueno_1.TraceUtil.traceResultingEither(ParseJsonMetric))).map(pengueno_1.TraceUtil.promiseify((traceableEitherJson) => traceableEitherJson.get().mapRight((j) => traceableEitherJson.move(j)).flatMap(jsonTransformer))).get();
+ exports2.jsonModel = jsonModel;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/filter/index.js
+var require_filter = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/filter/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.PenguenoError = exports2.ErrorSource = void 0;
+ var pengueno_1 = require_dist2();
+ var ErrorSource;
+ (function(ErrorSource2) {
+ ErrorSource2["USER"] = "WARN";
+ ErrorSource2["SYSTEM"] = "ERROR";
+ })(ErrorSource || (exports2.ErrorSource = ErrorSource = {}));
+ var PenguenoError = class extends Error {
+ message;
+ status;
+ source;
+ constructor(message, status) {
+ super(message);
+ this.message = message;
+ this.status = status;
+ this.source = Math.floor(status / 100) === 4 ? ErrorSource.USER : ErrorSource.SYSTEM;
+ }
+ };
+ exports2.PenguenoError = PenguenoError;
+ __exportStar(require_method2(), exports2);
+ __exportStar(require_json(), exports2);
+ }
+});
+
+// node_modules/hono/dist/cjs/http-exception.js
+var require_http_exception = __commonJS({
+ "node_modules/hono/dist/cjs/http-exception.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var HTTPException = class extends Error {
+ res;
+ status;
+ /**
+ * Creates an instance of `HTTPException`.
+ * @param status - HTTP status code for the exception. Defaults to 500.
+ * @param options - Additional options for the exception.
+ */
+ constructor(status = 500, options) {
+ super(options?.message, { cause: options?.cause });
+ this.res = options?.res;
+ this.status = status;
+ }
+ /**
+ * Returns the response object associated with the exception.
+ * If a response object is not provided, a new response is created with the error message and status code.
+ * @returns The response object.
+ */
+ getResponse() {
+ if (this.res) return new Response(this.res.body, {
+ status: this.status,
+ headers: this.res.headers
+ });
+ return new Response(this.message, { status: this.status });
+ }
+ };
+ exports2.HTTPException = HTTPException;
+ }
+});
+
+// node_modules/hono/dist/cjs/request/constants.js
+var require_constants = __commonJS({
+ "node_modules/hono/dist/cjs/request/constants.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var GET_MATCH_RESULT = Symbol();
+ exports2.GET_MATCH_RESULT = GET_MATCH_RESULT;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/crypto.js
+var require_crypto = __commonJS({
+ "node_modules/hono/dist/cjs/utils/crypto.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var sha256 = async (data) => {
+ return await createHash(data, {
+ name: "SHA-256",
+ alias: "sha256"
+ });
+ };
+ var sha1 = async (data) => {
+ return await createHash(data, {
+ name: "SHA-1",
+ alias: "sha1"
+ });
+ };
+ var md5 = async (data) => {
+ return await createHash(data, {
+ name: "MD5",
+ alias: "md5"
+ });
+ };
+ var createHash = async (data, algorithm) => {
+ let sourceBuffer;
+ if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) sourceBuffer = data;
+ else {
+ if (typeof data === "object") data = JSON.stringify(data);
+ sourceBuffer = new TextEncoder().encode(String(data));
+ }
+ if (crypto && crypto.subtle) {
+ const buffer = await crypto.subtle.digest({ name: algorithm.name }, sourceBuffer);
+ return Array.prototype.map.call(new Uint8Array(buffer), (x) => ("00" + x.toString(16)).slice(-2)).join("");
+ }
+ return null;
+ };
+ exports2.createHash = createHash;
+ exports2.md5 = md5;
+ exports2.sha1 = sha1;
+ exports2.sha256 = sha256;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/buffer.js
+var require_buffer = __commonJS({
+ "node_modules/hono/dist/cjs/utils/buffer.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_utils_crypto = require_crypto();
+ var equal = (a, b) => {
+ if (a === b) return true;
+ if (a.byteLength !== b.byteLength) return false;
+ const va = new DataView(a);
+ const vb = new DataView(b);
+ let i = va.byteLength;
+ while (i--) if (va.getUint8(i) !== vb.getUint8(i)) return false;
+ return true;
+ };
+ var constantTimeEqualString = (a, b) => {
+ const aLen = a.length;
+ const bLen = b.length;
+ const maxLen = Math.max(aLen, bLen);
+ let out = aLen ^ bLen;
+ for (let i = 0; i < maxLen; i++) {
+ const aChar = i < aLen ? a.charCodeAt(i) : 0;
+ const bChar = i < bLen ? b.charCodeAt(i) : 0;
+ out |= aChar ^ bChar;
+ }
+ return out === 0;
+ };
+ var timingSafeEqualString = async (a, b, hashFunction) => {
+ if (!hashFunction) hashFunction = require_utils_crypto.sha256;
+ const [sa, sb] = await Promise.all([hashFunction(a), hashFunction(b)]);
+ if (sa == null || sb == null || typeof sa !== "string" || typeof sb !== "string") return false;
+ const hashEqual = constantTimeEqualString(sa, sb);
+ const originalEqual = constantTimeEqualString(a, b);
+ return hashEqual && originalEqual;
+ };
+ var timingSafeEqual = async (a, b, hashFunction) => {
+ if (typeof a === "string" && typeof b === "string") return timingSafeEqualString(a, b, hashFunction);
+ if (!hashFunction) hashFunction = require_utils_crypto.sha256;
+ const [sa, sb] = await Promise.all([hashFunction(a), hashFunction(b)]);
+ if (!sa || !sb || typeof sa !== "string" || typeof sb !== "string") return false;
+ return timingSafeEqualString(sa, sb);
+ };
+ var bufferToString = (buffer) => {
+ if (buffer instanceof ArrayBuffer) return new TextDecoder("utf-8").decode(buffer);
+ return buffer;
+ };
+ var bufferToFormData = (arrayBuffer, contentType) => {
+ return new Response(arrayBuffer, { headers: { "Content-Type": contentType.replace(/^[^;]+/, (mediaType) => mediaType.toLowerCase()) } }).formData();
+ };
+ exports2.bufferToFormData = bufferToFormData;
+ exports2.bufferToString = bufferToString;
+ exports2.equal = equal;
+ exports2.timingSafeEqual = timingSafeEqual;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/body.js
+var require_body2 = __commonJS({
+ "node_modules/hono/dist/cjs/utils/body.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_utils_buffer = require_buffer();
+ var MAX_NESTED_OBJECTS = 1e4;
+ var isRawRequest = (request) => "headers" in request;
+ var parseBody = async (request, options = /* @__PURE__ */ Object.create(null)) => {
+ const { all = false, dot = false } = options;
+ const mediaType = (isRawRequest(request) ? request.headers : request.raw.headers).get("Content-Type")?.split(";")[0].trim().toLowerCase();
+ if (mediaType === "multipart/form-data" || mediaType === "application/x-www-form-urlencoded") return parseFormData(request, {
+ all,
+ dot
+ });
+ return {};
+ };
+ async function parseFormData(request, options) {
+ if (!isRawRequest(request) && request.bodyCache.formData) return convertFormDataToBodyData(await request.bodyCache.formData, options);
+ const headers = isRawRequest(request) ? request.headers : request.raw.headers;
+ const arrayBuffer = await request.arrayBuffer();
+ const formDataPromise = require_utils_buffer.bufferToFormData(arrayBuffer, headers.get("Content-Type") || "");
+ if (!isRawRequest(request)) request.bodyCache.formData = formDataPromise;
+ const formData = await formDataPromise;
+ if (formData) return convertFormDataToBodyData(formData, options);
+ return {};
+ }
+ function convertFormDataToBodyData(formData, options) {
+ const form = /* @__PURE__ */ Object.create(null);
+ const nestingState = { count: 0 };
+ formData.forEach((value, key) => {
+ if (!(options.all || key.endsWith("[]"))) form[key] = value;
+ else handleParsingAllValues(form, key, value);
+ });
+ if (options.dot) Object.entries(form).forEach(([key, value]) => {
+ if (key.includes(".")) {
+ handleParsingNestedValues(form, key, value, nestingState);
+ delete form[key];
+ }
+ });
+ return form;
+ }
+ var handleParsingAllValues = (form, key, value) => {
+ if (form[key] !== void 0) {
+ if (Array.isArray(form[key])) form[key].push(value);
+ else form[key] = [form[key], value];
+ } else if (!key.endsWith("[]")) form[key] = value;
+ else form[key] = [value];
+ };
+ var handleParsingNestedValues = (form, key, value, state) => {
+ if (/(?:^|\.)__proto__\./.test(key)) return;
+ let nestedForm = form;
+ const keys = key.split(".", 34);
+ if (keys.length > 33) throwNestingLimitExceeded();
+ keys.forEach((key2, index) => {
+ if (index === keys.length - 1) nestedForm[key2] = value;
+ else {
+ if (!nestedForm[key2] || typeof nestedForm[key2] !== "object" || Array.isArray(nestedForm[key2]) || nestedForm[key2] instanceof File) {
+ if (state.count++ >= MAX_NESTED_OBJECTS) throwNestingLimitExceeded();
+ nestedForm[key2] = /* @__PURE__ */ Object.create(null);
+ }
+ nestedForm = nestedForm[key2];
+ }
+ });
+ };
+ var throwNestingLimitExceeded = () => {
+ throw new Error("Nesting limit exceeded");
+ };
+ exports2.parseBody = parseBody;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/url.js
+var require_url = __commonJS({
+ "node_modules/hono/dist/cjs/utils/url.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var splitPath = (path) => {
+ const paths = path.split("/");
+ if (paths[0] === "") paths.shift();
+ return paths;
+ };
+ var splitRoutingPath = (routePath) => {
+ const { groups, path } = extractGroupsFromPath(routePath);
+ const paths = splitPath(path);
+ return replaceGroupMarks(paths, groups);
+ };
+ var extractGroupsFromPath = (path) => {
+ const groups = [];
+ path = path.replace(/\{[^}]+\}/g, (match, index) => {
+ const mark = `@${index}`;
+ groups.push([mark, match]);
+ return mark;
+ });
+ return {
+ groups,
+ path
+ };
+ };
+ var replaceGroupMarks = (paths, groups) => {
+ for (let i = groups.length - 1; i >= 0; i--) {
+ const [mark] = groups[i];
+ for (let j = paths.length - 1; j >= 0; j--) if (paths[j].includes(mark)) {
+ paths[j] = paths[j].replace(mark, groups[i][1]);
+ break;
+ }
+ }
+ return paths;
+ };
+ var patternCache = {};
+ var getPattern = (label, next) => {
+ if (label === "*") return "*";
+ const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/);
+ if (match) {
+ const cacheKey = `${label}#${next}`;
+ if (!patternCache[cacheKey]) {
+ if (match[2]) patternCache[cacheKey] = next && next[0] !== ":" && next[0] !== "*" ? [
+ cacheKey,
+ match[1],
+ new RegExp(`^${match[2]}(?=/${next})`)
+ ] : [
+ label,
+ match[1],
+ new RegExp(`^${match[2]}$`)
+ ];
+ else patternCache[cacheKey] = [
+ label,
+ match[1],
+ true
+ ];
+ }
+ return patternCache[cacheKey];
+ }
+ return null;
+ };
+ var tryDecode = (str, decoder) => {
+ try {
+ return decoder(str);
+ } catch {
+ return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => {
+ try {
+ return decoder(match);
+ } catch {
+ return match;
+ }
+ });
+ }
+ };
+ var tryDecodeURI = (str) => tryDecode(str, decodeURI);
+ var decodeURIWithPreservedPercent = (value) => decodeURI(value.includes("%25") ? value.replace(/%25/g, "%2525") : value);
+ var getPath = (request) => {
+ const url = request.url;
+ const start = url.indexOf("/", url.indexOf(":") + 4);
+ let i = start;
+ for (; i < url.length; i++) {
+ const charCode = url.charCodeAt(i);
+ if (charCode === 37) {
+ const queryIndex = url.indexOf("?", i);
+ const hashIndex = url.indexOf("#", i);
+ const end = queryIndex === -1 ? hashIndex === -1 ? void 0 : hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
+ const path = url.slice(start, end);
+ const decoded = tryDecode(path, decodeURIWithPreservedPercent);
+ if (/%(?![0-9A-Fa-f]{2})/.test(path)) {
+ const rewritten = new URL("http://localhost");
+ rewritten.pathname = decoded.replace(/[\\\t\n\r]/g, encodeURIComponent);
+ if (tryDecode(rewritten.pathname, decodeURIWithPreservedPercent) !== decoded) return tryDecode(path.replace(/%(?![0-9A-Fa-f]{2})/g, "%25"), decodeURIWithPreservedPercent);
+ }
+ return decoded;
+ } else if (charCode === 63 || charCode === 35) break;
+ }
+ return url.slice(start, i);
+ };
+ var getQueryStrings = (url) => {
+ const queryIndex = url.indexOf("?", 8);
+ if (queryIndex === -1) return "";
+ const hashIndex = url.indexOf("#", 8);
+ return hashIndex === -1 ? url.slice(queryIndex) : queryIndex < hashIndex ? url.slice(queryIndex, hashIndex) : "";
+ };
+ var getPathNoStrict = (request) => {
+ const result = getPath(request);
+ return result.length > 1 && result.at(-1) === "/" ? result.slice(0, -1) : result;
+ };
+ var mergePath = (base, sub, ...rest) => {
+ if (rest.length) sub = mergePath(sub, ...rest);
+ return `${base?.[0] === "/" ? "" : "/"}${base}${sub === "/" ? "" : `${base?.at(-1) === "/" ? "" : "/"}${sub?.[0] === "/" ? sub.slice(1) : sub}`}`;
+ };
+ var checkOptionalParameter = (path) => {
+ if (path.charCodeAt(path.length - 1) !== 63 || !path.includes(":")) return null;
+ const segments = path.split("/");
+ const results = [];
+ let basePath = "";
+ segments.forEach((segment) => {
+ if (segment !== "" && !/\:/.test(segment)) basePath += "/" + segment;
+ else if (/\:/.test(segment)) {
+ if (segment.charCodeAt(segment.length - 1) === 63) {
+ if (results.length === 0 && basePath === "") results.push("/");
+ else results.push(basePath);
+ const optionalSegment = segment.slice(0, -1);
+ basePath += "/" + optionalSegment;
+ results.push(basePath);
+ } else basePath += "/" + segment;
+ }
+ });
+ return results.filter((v, i, a) => a.indexOf(v) === i);
+ };
+ var tryDecodeURIComponent = (str) => str.indexOf("%") !== -1 ? tryDecode(str, decodeURIComponent_) : str;
+ var _decodeURI = (value) => {
+ if (value.indexOf("+") !== -1) value = value.replace(/\+/g, " ");
+ return tryDecodeURIComponent(value);
+ };
+ var _getQueryParam = (url, key, multiple) => {
+ const hashIndex = url.indexOf("#", 8);
+ if (hashIndex !== -1) url = url.slice(0, hashIndex);
+ let encoded;
+ if (!multiple && key && !/[%+&=]/.test(key)) {
+ const queryIndex = url.indexOf("?", 8);
+ let keyIndex2 = queryIndex;
+ if (keyIndex2 === -1) return;
+ if (!url.startsWith(key, keyIndex2 + 1)) keyIndex2 = url.indexOf(`&${key}`, keyIndex2 + 1);
+ while (keyIndex2 !== -1) {
+ const trailingKeyCode = url.charCodeAt(keyIndex2 + key.length + 1);
+ if (trailingKeyCode === 61 || trailingKeyCode === 38 || isNaN(trailingKeyCode)) {
+ if (keyIndex2 > queryIndex && /(?:^|&)[^=&]*[%+]/.test(url.slice(queryIndex + 1, keyIndex2))) break;
+ if (trailingKeyCode === 61) {
+ const valueIndex = keyIndex2 + key.length + 2;
+ const endIndex = url.indexOf("&", valueIndex);
+ return _decodeURI(url.slice(valueIndex, endIndex === -1 ? void 0 : endIndex));
+ }
+ return "";
+ }
+ keyIndex2 = url.indexOf(`&${key}`, keyIndex2 + 1);
+ }
+ encoded = /[%+]/.test(url);
+ if (!encoded) return;
+ }
+ const results = /* @__PURE__ */ Object.create(null);
+ encoded ??= /[%+]/.test(url);
+ let keyIndex = url.indexOf("?", 8);
+ while (keyIndex !== -1) {
+ const nextKeyIndex = url.indexOf("&", keyIndex + 1);
+ let valueIndex = url.indexOf("=", keyIndex);
+ if (valueIndex > nextKeyIndex && nextKeyIndex !== -1) valueIndex = -1;
+ let name = url.slice(keyIndex + 1, valueIndex === -1 ? nextKeyIndex === -1 ? void 0 : nextKeyIndex : valueIndex);
+ if (encoded) name = _decodeURI(name);
+ keyIndex = nextKeyIndex;
+ if (name === "") continue;
+ let value;
+ if (valueIndex === -1) value = "";
+ else {
+ value = url.slice(valueIndex + 1, nextKeyIndex === -1 ? void 0 : nextKeyIndex);
+ if (encoded) value = _decodeURI(value);
+ }
+ if (multiple) {
+ if (!(results[name] && Array.isArray(results[name]))) results[name] = [];
+ results[name].push(value);
+ } else results[name] ??= value;
+ }
+ return key ? results[key] : results;
+ };
+ var getQueryParam = _getQueryParam;
+ var getQueryParams = (url, key) => {
+ return _getQueryParam(url, key, true);
+ };
+ var decodeURIComponent_ = decodeURIComponent;
+ exports2.checkOptionalParameter = checkOptionalParameter;
+ exports2.decodeURIComponent_ = decodeURIComponent_;
+ exports2.getPath = getPath;
+ exports2.getPathNoStrict = getPathNoStrict;
+ exports2.getPattern = getPattern;
+ exports2.getQueryParam = getQueryParam;
+ exports2.getQueryParams = getQueryParams;
+ exports2.getQueryStrings = getQueryStrings;
+ exports2.mergePath = mergePath;
+ exports2.splitPath = splitPath;
+ exports2.splitRoutingPath = splitRoutingPath;
+ exports2.tryDecode = tryDecode;
+ exports2.tryDecodeURI = tryDecodeURI;
+ exports2.tryDecodeURIComponent = tryDecodeURIComponent;
+ }
+});
+
+// node_modules/hono/dist/cjs/request.js
+var require_request2 = __commonJS({
+ "node_modules/hono/dist/cjs/request.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_http_exception2 = require_http_exception();
+ var require_request_constants = require_constants();
+ var require_utils_body = require_body2();
+ var require_utils_url = require_url();
+ var HonoRequest = class {
+ /**
+ * `.raw` can get the raw Request object.
+ *
+ * @see {@link https://hono.dev/docs/api/request#raw}
+ *
+ * @example
+ * ```ts
+ * // For Cloudflare Workers
+ * app.post('/', async (c) => {
+ * const metadata = c.req.raw.cf?.hostMetadata?
+ * ...
+ * })
+ * ```
+ */
+ raw;
+ #validatedData;
+ #matchResult;
+ routeIndex = 0;
+ /**
+ * `.path` can get the pathname of the request.
+ *
+ * @see {@link https://hono.dev/docs/api/request#path}
+ *
+ * @example
+ * ```ts
+ * app.get('/about/me', (c) => {
+ * const pathname = c.req.path // `/about/me`
+ * })
+ * ```
+ */
+ path;
+ bodyCache = {};
+ constructor(request, path = "/", matchResult = [[]]) {
+ this.raw = request;
+ this.path = path;
+ this.#matchResult = matchResult;
+ }
+ param(key) {
+ return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams();
+ }
+ #getDecodedParam(key) {
+ const paramKey = this.#matchResult[0][this.routeIndex]?.[1][key];
+ const param = this.#getParamValue(paramKey);
+ return param && require_utils_url.tryDecodeURIComponent(param);
+ }
+ #getAllDecodedParams() {
+ const decoded = {};
+ const keys = Object.keys(this.#matchResult[0][this.routeIndex]?.[1] ?? {});
+ for (const key of keys) {
+ const value = this.#getParamValue(this.#matchResult[0][this.routeIndex][1][key]);
+ if (value !== void 0) decoded[key] = require_utils_url.tryDecodeURIComponent(value);
+ }
+ return decoded;
+ }
+ #getParamValue(paramKey) {
+ return this.#matchResult[1] ? this.#matchResult[1][paramKey] : paramKey;
+ }
+ query(key) {
+ return require_utils_url.getQueryParam(this.url, key);
+ }
+ queries(key) {
+ return require_utils_url.getQueryParams(this.url, key);
+ }
+ header(name) {
+ if (name) return this.raw.headers.get(name) ?? void 0;
+ const headerData = /* @__PURE__ */ Object.create(null);
+ this.raw.headers.forEach((value, key) => {
+ headerData[key] = value;
+ });
+ return headerData;
+ }
+ async parseBody(options) {
+ return require_utils_body.parseBody(this, options);
+ }
+ #cachedBody = (key) => {
+ const { bodyCache, raw } = this;
+ const cachedBody = bodyCache[key];
+ if (cachedBody) return cachedBody;
+ for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body) => {
+ if (anyCachedKey === "json") body = JSON.stringify(body);
+ const contentType = anyCachedKey === "formData" ? void 0 : raw.headers.get("content-type");
+ return new Response(body, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
+ });
+ return bodyCache[key] = raw[key]();
+ };
+ /**
+ * `.json()` can parse Request body of type `application/json`
+ *
+ * @see {@link https://hono.dev/docs/api/request#json}
+ *
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.json()
+ * })
+ * ```
+ */
+ json() {
+ return this.#cachedBody("text").then((text) => JSON.parse(text));
+ }
+ /**
+ * `.text()` can parse Request body of type `text/plain`
+ *
+ * @see {@link https://hono.dev/docs/api/request#text}
+ *
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.text()
+ * })
+ * ```
+ */
+ text() {
+ return this.#cachedBody("text");
+ }
+ /**
+ * `.arrayBuffer()` parse Request body as an `ArrayBuffer`
+ *
+ * @see {@link https://hono.dev/docs/api/request#arraybuffer}
+ *
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.arrayBuffer()
+ * })
+ * ```
+ */
+ arrayBuffer() {
+ return this.#cachedBody("arrayBuffer");
+ }
+ /**
+ * `.bytes()` parses the request body as a `Uint8Array`.
+ *
+ * @see {@link https://hono.dev/docs/api/request#bytes}
+ *
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.bytes()
+ * })
+ * ```
+ */
+ bytes() {
+ return this.#cachedBody("arrayBuffer").then((buffer) => new Uint8Array(buffer));
+ }
+ /**
+ * Parses the request body as a `Blob`.
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.blob();
+ * });
+ * ```
+ * @see https://hono.dev/docs/api/request#blob
+ */
+ blob() {
+ return this.#cachedBody("blob");
+ }
+ /**
+ * Parses the request body as `FormData`.
+ * @example
+ * ```ts
+ * app.post('/entry', async (c) => {
+ * const body = await c.req.formData();
+ * });
+ * ```
+ * @see https://hono.dev/docs/api/request#formdata
+ */
+ formData() {
+ return this.#cachedBody("formData");
+ }
+ /**
+ * Adds validated data to the request.
+ *
+ * @param target - The target of the validation.
+ * @param data - The validated data to add.
+ */
+ addValidatedData(target, data) {
+ (this.#validatedData ??= {})[target] = data;
+ }
+ valid(target) {
+ return this.#validatedData?.[target];
+ }
+ /**
+ * `.url` can get the request url strings.
+ *
+ * @see {@link https://hono.dev/docs/api/request#url}
+ *
+ * @example
+ * ```ts
+ * app.get('/about/me', (c) => {
+ * const url = c.req.url // `http://localhost:8787/about/me`
+ * ...
+ * })
+ * ```
+ */
+ get url() {
+ return this.raw.url;
+ }
+ /**
+ * `.method` can get the method name of the request.
+ *
+ * @see {@link https://hono.dev/docs/api/request#method}
+ *
+ * @example
+ * ```ts
+ * app.get('/about/me', (c) => {
+ * const method = c.req.method // `GET`
+ * })
+ * ```
+ */
+ get method() {
+ return this.raw.method;
+ }
+ get [require_request_constants.GET_MATCH_RESULT]() {
+ return this.#matchResult;
+ }
+ /**
+ * `.matchedRoutes` can return a matched route in the handler
+ *
+ * @deprecated
+ *
+ * Use matchedRoutes helper defined in "hono/route" instead.
+ *
+ * @see {@link https://hono.dev/docs/api/request#matchedroutes}
+ *
+ * @example
+ * ```ts
+ * app.use('*', async function logger(c, next) {
+ * await next()
+ * c.req.matchedRoutes.forEach(({ handler, method, path }, i) => {
+ * const name = handler.name || (handler.length < 2 ? '[handler]' : '[middleware]')
+ * console.log(
+ * method,
+ * ' ',
+ * path,
+ * ' '.repeat(Math.max(10 - path.length, 0)),
+ * name,
+ * i === c.req.routeIndex ? '<- respond from here' : ''
+ * )
+ * })
+ * })
+ * ```
+ */
+ get matchedRoutes() {
+ return this.#matchResult[0].map(([[, route]]) => route);
+ }
+ /**
+ * `.routePath` can retrieve the path registered within the handler
+ *
+ * @deprecated
+ *
+ * Use routePath helper defined in "hono/route" instead.
+ *
+ * @see {@link https://hono.dev/docs/api/request#routepath}
+ *
+ * @example
+ * ```ts
+ * app.get('/posts/:id', (c) => {
+ * return c.json({ path: c.req.routePath })
+ * })
+ * ```
+ */
+ get routePath() {
+ return this.#matchResult[0].map(([[, route]]) => route)[this.routeIndex].path;
+ }
+ };
+ var cloneRawRequest = async (req) => {
+ if (!req.raw.bodyUsed) return req.raw.clone();
+ const cacheKey = Object.keys(req.bodyCache)[0];
+ if (!cacheKey) throw new require_http_exception2.HTTPException(500, { message: "Cannot clone request: body was already consumed and not cached. Please use HonoRequest methods (e.g., req.json(), req.text()) instead of consuming req.raw directly." });
+ let body = await req[cacheKey]();
+ const headers = req.header();
+ if (cacheKey === "json") {
+ body = JSON.stringify(body);
+ delete headers["content-length"];
+ } else if (body instanceof FormData) {
+ delete headers["content-type"];
+ delete headers["content-length"];
+ }
+ const requestInit = {
+ body,
+ cache: req.raw.cache,
+ credentials: req.raw.credentials,
+ headers,
+ integrity: req.raw.integrity,
+ keepalive: req.raw.keepalive,
+ method: req.method,
+ mode: req.raw.mode,
+ redirect: req.raw.redirect,
+ referrer: req.raw.referrer,
+ referrerPolicy: req.raw.referrerPolicy,
+ signal: req.raw.signal
+ };
+ return new Request(req.url, requestInit);
+ };
+ exports2.HonoRequest = HonoRequest;
+ exports2.cloneRawRequest = cloneRawRequest;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/html.js
+var require_html = __commonJS({
+ "node_modules/hono/dist/cjs/utils/html.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var HtmlEscapedCallbackPhase = {
+ Stringify: 1,
+ BeforeStream: 2,
+ Stream: 3
+ };
+ var raw = (value, callbacks) => {
+ const escapedString = new String(value);
+ escapedString.isEscaped = true;
+ escapedString.callbacks = callbacks;
+ return escapedString;
+ };
+ var escapeRe = /[&<>'"]/;
+ var stringBufferToString = async (buffer, callbacks) => {
+ let str = "";
+ callbacks ||= [];
+ const resolvedBuffer = await Promise.all(buffer);
+ for (let i = resolvedBuffer.length - 1; ; i--) {
+ str += resolvedBuffer[i];
+ i--;
+ if (i < 0) break;
+ let r = resolvedBuffer[i];
+ if (typeof r === "object") callbacks.push(...r.callbacks || []);
+ const isEscaped = r.isEscaped;
+ r = await (typeof r === "object" ? r.toString() : r);
+ if (typeof r === "object") callbacks.push(...r.callbacks || []);
+ if (r.isEscaped ?? isEscaped) str += r;
+ else {
+ const buf = [str];
+ escapeToBuffer(r, buf);
+ str = buf[0];
+ }
+ }
+ return raw(str, callbacks);
+ };
+ var escapeToBuffer = (str, buffer) => {
+ const match = str.search(escapeRe);
+ if (match === -1) {
+ buffer[0] += str;
+ return;
+ }
+ let escape;
+ let index;
+ let lastIndex = 0;
+ for (index = match; index < str.length; index++) {
+ switch (str.charCodeAt(index)) {
+ case 34:
+ escape = "&quot;";
+ break;
+ case 39:
+ escape = "&#39;";
+ break;
+ case 38:
+ escape = "&amp;";
+ break;
+ case 60:
+ escape = "&lt;";
+ break;
+ case 62:
+ escape = "&gt;";
+ break;
+ default:
+ continue;
+ }
+ buffer[0] += str.substring(lastIndex, index) + escape;
+ lastIndex = index + 1;
+ }
+ buffer[0] += str.substring(lastIndex, index);
+ };
+ var resolveCallbackSync = (str) => {
+ const callbacks = str.callbacks;
+ if (!callbacks?.length) return str;
+ const buffer = [str];
+ const context = {};
+ callbacks.forEach((c) => c({
+ phase: HtmlEscapedCallbackPhase.Stringify,
+ buffer,
+ context
+ }));
+ return buffer[0];
+ };
+ var resolveCallback = async (str, phase, preserveCallbacks, context, buffer) => {
+ if (typeof str === "object" && !(str instanceof String)) {
+ if (!(str instanceof Promise)) str = str.toString();
+ if (str instanceof Promise) str = await str;
+ }
+ const callbacks = str.callbacks;
+ if (!callbacks?.length) return Promise.resolve(str);
+ if (buffer) buffer[0] += str;
+ else buffer = [str];
+ const resStr = Promise.all(callbacks.map((c) => c({
+ phase,
+ buffer,
+ context
+ }))).then((res) => Promise.all(res.filter(Boolean).map((str2) => resolveCallback(str2, phase, false, context, buffer))).then(() => buffer[0]));
+ if (preserveCallbacks) return raw(await resStr, callbacks);
+ else return resStr;
+ };
+ exports2.HtmlEscapedCallbackPhase = HtmlEscapedCallbackPhase;
+ exports2.escapeToBuffer = escapeToBuffer;
+ exports2.raw = raw;
+ exports2.resolveCallback = resolveCallback;
+ exports2.resolveCallbackSync = resolveCallbackSync;
+ exports2.stringBufferToString = stringBufferToString;
+ }
+});
+
+// node_modules/hono/dist/cjs/context.js
+var require_context = __commonJS({
+ "node_modules/hono/dist/cjs/context.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_request3 = require_request2();
+ var require_utils_html = require_html();
+ var TEXT_PLAIN = "text/plain; charset=UTF-8";
+ var setDefaultContentType = (contentType, headers) => {
+ return {
+ "Content-Type": contentType,
+ ...headers
+ };
+ };
+ var createResponseInstance = (body, init) => new Response(body, init);
+ var Context = class {
+ #rawRequest;
+ #req;
+ /**
+ * `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers.
+ *
+ * @see {@link https://hono.dev/docs/api/context#env}
+ *
+ * @example
+ * ```ts
+ * // Environment object for Cloudflare Workers
+ * app.get('*', async c => {
+ * const counter = c.env.COUNTER
+ * })
+ * ```
+ */
+ env = {};
+ #var;
+ finalized = false;
+ /**
+ * `.error` can get the error object from the middleware if the Handler throws an error.
+ *
+ * @see {@link https://hono.dev/docs/api/context#error}
+ *
+ * @example
+ * ```ts
+ * app.use('*', async (c, next) => {
+ * await next()
+ * if (c.error) {
+ * // do something...
+ * }
+ * })
+ * ```
+ */
+ error;
+ #status;
+ #executionCtx;
+ #res;
+ #layout;
+ #renderer;
+ #notFoundHandler;
+ #preparedHeaders;
+ #matchResult;
+ #path;
+ /**
+ * Creates an instance of the Context class.
+ *
+ * @param req - The Request object.
+ * @param options - Optional configuration options for the context.
+ */
+ constructor(req, options) {
+ this.#rawRequest = req;
+ if (options) {
+ this.#executionCtx = options.executionCtx;
+ this.env = options.env;
+ this.#notFoundHandler = options.notFoundHandler;
+ this.#path = options.path;
+ this.#matchResult = options.matchResult;
+ }
+ }
+ /**
+ * `.req` is the instance of {@link HonoRequest}.
+ */
+ get req() {
+ this.#req ??= new require_request3.HonoRequest(this.#rawRequest, this.#path, this.#matchResult);
+ return this.#req;
+ }
+ /**
+ * @see {@link https://hono.dev/docs/api/context#event}
+ * The FetchEvent associated with the current request.
+ *
+ * @throws Will throw an error if the context does not have a FetchEvent.
+ */
+ get event() {
+ if (this.#executionCtx && "respondWith" in this.#executionCtx) return this.#executionCtx;
+ else throw Error("This context has no FetchEvent");
+ }
+ /**
+ * @see {@link https://hono.dev/docs/api/context#executionctx}
+ * The ExecutionContext associated with the current request.
+ *
+ * @throws Will throw an error if the context does not have an ExecutionContext.
+ */
+ get executionCtx() {
+ if (this.#executionCtx) return this.#executionCtx;
+ else throw Error("This context has no ExecutionContext");
+ }
+ /**
+ * @see {@link https://hono.dev/docs/api/context#res}
+ * The Response object for the current request.
+ */
+ get res() {
+ return this.#res ||= createResponseInstance(null, { headers: this.#preparedHeaders ??= new Headers() });
+ }
+ /**
+ * Sets the Response object for the current request.
+ *
+ * @param _res - The Response object to set.
+ */
+ set res(_res) {
+ if (this.#res && _res) {
+ _res = createResponseInstance(_res.body, _res);
+ for (const [k, v] of this.#res.headers.entries()) {
+ if (k === "content-type") continue;
+ if (k === "set-cookie") {
+ const cookies = this.#res.headers.getSetCookie();
+ _res.headers.delete("set-cookie");
+ for (const cookie of cookies) _res.headers.append("set-cookie", cookie);
+ } else _res.headers.set(k, v);
+ }
+ }
+ this.#res = _res;
+ this.finalized = true;
+ }
+ /**
+ * `.render()` can create a response within a layout.
+ *
+ * @see {@link https://hono.dev/docs/api/context#render-setrenderer}
+ *
+ * @example
+ * ```ts
+ * app.get('/', (c) => {
+ * return c.render('Hello!')
+ * })
+ * ```
+ */
+ render = (...args) => {
+ this.#renderer ??= (content) => this.html(content);
+ return this.#renderer(...args);
+ };
+ /**
+ * Sets the layout for the response.
+ *
+ * @param layout - The layout to set.
+ * @returns The layout function.
+ */
+ setLayout = (layout) => this.#layout = layout;
+ /**
+ * Gets the current layout for the response.
+ *
+ * @returns The current layout function.
+ */
+ getLayout = () => this.#layout;
+ /**
+ * `.setRenderer()` can set the layout in the custom middleware.
+ *
+ * @see {@link https://hono.dev/docs/api/context#render-setrenderer}
+ *
+ * @example
+ * ```tsx
+ * app.use('*', async (c, next) => {
+ * c.setRenderer((content) => {
+ * return c.html(
+ * <html>
+ * <body>
+ * <p>{content}</p>
+ * </body>
+ * </html>
+ * )
+ * })
+ * await next()
+ * })
+ * ```
+ */
+ setRenderer = (renderer) => {
+ this.#renderer = renderer;
+ };
+ /**
+ * `.header()` can set headers.
+ *
+ * @see {@link https://hono.dev/docs/api/context#header}
+ *
+ * @example
+ * ```ts
+ * app.get('/welcome', (c) => {
+ * // Set headers
+ * c.header('X-Message', 'Hello!')
+ * c.header('Content-Type', 'text/plain')
+ *
+ * // Append multiple headers using the append option (e.g. Vary)
+ * c.header('Vary', 'Accept-Encoding', { append: true })
+ * c.header('Vary', 'User-Agent', { append: true })
+ *
+ * return c.body('Thank you for coming')
+ * })
+ * ```
+ */
+ header = (name, value, options) => {
+ if (this.finalized && !this.#res.bodyUsed) this.#res = createResponseInstance(this.#res.body, this.#res);
+ const headers = this.#res ? this.#res.headers : this.#preparedHeaders ??= new Headers();
+ if (value === void 0) headers.delete(name);
+ else if (options?.append) headers.append(name, value);
+ else headers.set(name, value);
+ };
+ status = (status) => {
+ this.#status = status;
+ };
+ /**
+ * `.set()` can set the value specified by the key.
+ *
+ * @see {@link https://hono.dev/docs/api/context#set-get}
+ *
+ * @example
+ * ```ts
+ * app.use('*', async (c, next) => {
+ * c.set('message', 'Hono is hot!!')
+ * await next()
+ * })
+ * ```
+ */
+ set = (key, value) => {
+ this.#var ??= /* @__PURE__ */ new Map();
+ this.#var.set(key, value);
+ };
+ /**
+ * `.get()` can use the value specified by the key.
+ *
+ * @see {@link https://hono.dev/docs/api/context#set-get}
+ *
+ * @example
+ * ```ts
+ * app.get('/', (c) => {
+ * const message = c.get('message')
+ * return c.text(`The message is "${message}"`)
+ * })
+ * ```
+ */
+ get = (key) => {
+ return this.#var ? this.#var.get(key) : void 0;
+ };
+ /**
+ * `.var` can access the value of a variable.
+ *
+ * @see {@link https://hono.dev/docs/api/context#var}
+ *
+ * @example
+ * ```ts
+ * const result = c.var.client.oneMethod()
+ * ```
+ */
+ get var() {
+ if (!this.#var) return {};
+ return Object.fromEntries(this.#var);
+ }
+ #newResponse(data, arg, headers) {
+ let responseHeaders = this.#res ? new Headers(this.#res.headers) : this.#preparedHeaders;
+ if (typeof arg === "object" && arg.headers) {
+ responseHeaders ??= new Headers();
+ for (const [key, value] of new Headers(arg.headers)) if (key === "set-cookie") responseHeaders.append(key, value);
+ else responseHeaders.set(key, value);
+ }
+ if (headers) {
+ if (!responseHeaders) {
+ let count = 0;
+ for (const k in headers) if (++count > 1 || typeof headers[k] !== "string") {
+ responseHeaders = new Headers();
+ break;
+ }
+ }
+ if (responseHeaders) for (const k in headers) {
+ const v = headers[k];
+ if (typeof v === "string") responseHeaders.set(k, v);
+ else {
+ responseHeaders.delete(k);
+ for (const v2 of v) responseHeaders.append(k, v2);
+ }
+ }
+ }
+ const status = typeof arg === "number" ? arg : arg?.status ?? this.#status;
+ return createResponseInstance(data, {
+ status,
+ headers: responseHeaders ?? headers
+ });
+ }
+ newResponse = (...args) => this.#newResponse(...args);
+ /**
+ * `.body()` can return the HTTP response.
+ * You can set headers with `.header()` and set HTTP status code with `.status`.
+ * This can also be set in `.text()`, `.json()` and so on.
+ *
+ * @see {@link https://hono.dev/docs/api/context#body}
+ *
+ * @example
+ * ```ts
+ * app.get('/welcome', (c) => {
+ * // Set headers
+ * c.header('X-Message', 'Hello!')
+ * c.header('Content-Type', 'text/plain')
+ * // Set HTTP status code
+ * c.status(201)
+ *
+ * // Return the response body
+ * return c.body('Thank you for coming')
+ * })
+ * ```
+ */
+ body = (data, arg, headers) => this.#newResponse(data, arg, headers);
+ /**
+ * `.text()` can render text as `Content-Type:text/plain`.
+ *
+ * @see {@link https://hono.dev/docs/api/context#text}
+ *
+ * @example
+ * ```ts
+ * app.get('/say', (c) => {
+ * return c.text('Hello!')
+ * })
+ * ```
+ */
+ text = (text, arg, headers) => {
+ return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized ? new Response(text) : this.#newResponse(text, arg, setDefaultContentType(TEXT_PLAIN, headers));
+ };
+ /**
+ * `.json()` can render JSON as `Content-Type:application/json`.
+ *
+ * @see {@link https://hono.dev/docs/api/context#json}
+ *
+ * @example
+ * ```ts
+ * app.get('/api', (c) => {
+ * return c.json({ message: 'Hello!' })
+ * })
+ * ```
+ */
+ json = (object, arg, headers) => {
+ return this.#newResponse(JSON.stringify(object), arg, setDefaultContentType("application/json", headers));
+ };
+ html = (html, arg, headers) => {
+ const res = (html2) => this.#newResponse(html2, arg, setDefaultContentType("text/html; charset=UTF-8", headers));
+ return typeof html === "object" ? require_utils_html.resolveCallback(html, require_utils_html.HtmlEscapedCallbackPhase.Stringify, false, {}).then(res) : res(html);
+ };
+ /**
+ * `.redirect()` can Redirect, default status code is 302.
+ *
+ * @see {@link https://hono.dev/docs/api/context#redirect}
+ *
+ * @example
+ * ```ts
+ * app.get('/redirect', (c) => {
+ * return c.redirect('/')
+ * })
+ * app.get('/redirect-permanently', (c) => {
+ * return c.redirect('/', 301)
+ * })
+ * ```
+ */
+ redirect = (location, status) => {
+ const locationString = String(location);
+ this.header("Location", !/[^\x00-\xFF]/.test(locationString) ? locationString : encodeURI(locationString));
+ return this.newResponse(null, status ?? 302);
+ };
+ /**
+ * `.notFound()` can return the Not Found Response.
+ *
+ * @see {@link https://hono.dev/docs/api/context#notfound}
+ *
+ * @example
+ * ```ts
+ * app.get('/notfound', (c) => {
+ * return c.notFound()
+ * })
+ * ```
+ */
+ notFound = () => {
+ this.#notFoundHandler ??= () => createResponseInstance();
+ return this.#notFoundHandler(this);
+ };
+ };
+ exports2.Context = Context;
+ exports2.TEXT_PLAIN = TEXT_PLAIN;
+ }
+});
+
+// node_modules/hono/dist/cjs/compose.js
+var require_compose = __commonJS({
+ "node_modules/hono/dist/cjs/compose.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var compose = (middleware, onError, onNotFound) => {
+ return (context, next) => {
+ let index = -1;
+ return dispatch(0);
+ async function dispatch(i) {
+ if (i <= index) throw new Error("next() called multiple times");
+ index = i;
+ let res;
+ let isError = false;
+ let handler;
+ if (middleware[i]) {
+ handler = middleware[i][0][0];
+ context.req.routeIndex = i;
+ } else handler = i === middleware.length && next || void 0;
+ if (handler) try {
+ res = await handler(context, () => dispatch(i + 1));
+ } catch (err) {
+ if (err instanceof Error && onError) {
+ context.error = err;
+ res = await onError(err, context);
+ isError = true;
+ } else throw err;
+ }
+ else if (context.finalized === false && onNotFound) res = await onNotFound(context);
+ if (res && (context.finalized === false || isError)) context.res = res;
+ return context;
+ }
+ };
+ };
+ exports2.compose = compose;
+ }
+});
+
+// node_modules/hono/dist/cjs/helper/route/index.js
+var require_route = __commonJS({
+ "node_modules/hono/dist/cjs/helper/route/index.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_request_constants = require_constants();
+ var require_utils_url = require_url();
+ var matchedRoutes = (c) => c.req[require_request_constants.GET_MATCH_RESULT][0].map(([[, route]]) => route);
+ var routePath = (c, index) => matchedRoutes(c).at(index ?? c.req.routeIndex)?.path ?? "";
+ var baseRoutePath = (c, index) => matchedRoutes(c).at(index ?? c.req.routeIndex)?.basePath ?? "";
+ var basePathCacheMap = /* @__PURE__ */ new WeakMap();
+ var basePath = (c, index) => {
+ index ??= c.req.routeIndex;
+ const cache = basePathCacheMap.get(c) || [];
+ if (typeof cache[index] === "string") return cache[index];
+ let result;
+ const rp = baseRoutePath(c, index);
+ if (!/[:*]/.test(rp)) result = rp;
+ else {
+ const paths = require_utils_url.splitRoutingPath(rp);
+ const reqPath = c.req.path;
+ let basePathLength = 0;
+ for (let i = 0, len = paths.length; i < len; i++) {
+ const pattern = require_utils_url.getPattern(paths[i], paths[i + 1]);
+ if (pattern) {
+ const re = pattern[2] === true || pattern === "*" ? /[^\/]+/ : pattern[2];
+ const restPath = reqPath.substring(basePathLength + 1);
+ const segment = restPath.split("/", 1)[0];
+ basePathLength += (restPath.match(re)?.[0] ?? (re.test(segment) ? segment : "")).length;
+ } else basePathLength += paths[i].length;
+ basePathLength += 1;
+ }
+ result = reqPath.substring(0, basePathLength);
+ }
+ cache[index] = result;
+ basePathCacheMap.set(c, cache);
+ return result;
+ };
+ exports2.basePath = basePath;
+ exports2.baseRoutePath = baseRoutePath;
+ exports2.matchedRoutes = matchedRoutes;
+ exports2.routePath = routePath;
+ }
+});
+
+// node_modules/hono/dist/cjs/router.js
+var require_router = __commonJS({
+ "node_modules/hono/dist/cjs/router.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var METHOD_NAME_ALL = "ALL";
+ var METHOD_NAME_ALL_LOWERCASE = "all";
+ var METHODS = [
+ "get",
+ "post",
+ "put",
+ "delete",
+ "options",
+ "patch",
+ "query"
+ ];
+ var MESSAGE_MATCHER_IS_ALREADY_BUILT = "Can not add a route since the matcher is already built.";
+ var UnsupportedPathError = class extends Error {
+ };
+ exports2.MESSAGE_MATCHER_IS_ALREADY_BUILT = MESSAGE_MATCHER_IS_ALREADY_BUILT;
+ exports2.METHODS = METHODS;
+ exports2.METHOD_NAME_ALL = METHOD_NAME_ALL;
+ exports2.METHOD_NAME_ALL_LOWERCASE = METHOD_NAME_ALL_LOWERCASE;
+ exports2.UnsupportedPathError = UnsupportedPathError;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/constants.js
+var require_constants2 = __commonJS({
+ "node_modules/hono/dist/cjs/utils/constants.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var COMPOSED_HANDLER = "__COMPOSED_HANDLER";
+ exports2.COMPOSED_HANDLER = COMPOSED_HANDLER;
+ }
+});
+
+// node_modules/hono/dist/cjs/utils/mount.js
+var require_mount = __commonJS({
+ "node_modules/hono/dist/cjs/utils/mount.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_http_exception2 = require_http_exception();
+ var require_request_constants = require_constants();
+ var require_utils_url = require_url();
+ var countTrailingSlashes = (path) => {
+ let index = path.length;
+ while (index > 0 && path.charCodeAt(index - 1) === 47) index--;
+ return path.length - index;
+ };
+ var getPathPrefixLength = (c, pathPrefix, routePath) => {
+ if (!/[:*]/.test(pathPrefix)) return pathPrefix.length;
+ const path = c.req.path;
+ const matchResult = c.req[require_request_constants.GET_MATCH_RESULT];
+ const paramNames = /* @__PURE__ */ new Set();
+ let length = 0;
+ for (const part of require_utils_url.splitRoutingPath(pathPrefix)) {
+ if (part[0] === ":" && part.endsWith("}?")) throw new Error(`mount(): Optional regular expression parameters are not supported in "${routePath}".`);
+ const pattern = require_utils_url.getPattern(part);
+ if (pattern && pattern !== "*") {
+ const name = pattern[1];
+ if (paramNames.has(name)) throw new Error(`mount(): Duplicate path parameter "${name}" in "${routePath}".`);
+ paramNames.add(name);
+ }
+ if (pattern && pattern !== "*" && pattern[2] !== true) {
+ const value = matchResult.length === 2 ? matchResult[1][matchResult[0][c.req.routeIndex][1][pattern[1]]] : matchResult[0][c.req.routeIndex][1][pattern[1]];
+ length += value.length + 1;
+ } else if (pattern || part.endsWith("*")) {
+ const end = path.indexOf("/", length + 1);
+ length = end === -1 ? path.length : end;
+ } else length += part.length + 1;
+ }
+ return length;
+ };
+ var createMountedRequest = (c, pathPrefix, routePath) => {
+ if (/[^/]\*$/.test(routePath)) throw new Error(`mount(): Wildcard must follow "/" in "${routePath}".`);
+ const prefixLength = getPathPrefixLength(c, pathPrefix, routePath);
+ const path = c.req.path.slice(prefixLength) || "/";
+ if (prefixLength > c.req.path.length || path[0] !== "/" || /\/(?:\.|%2e){1,2}(?:\/|$)/i.test(path)) throw new require_http_exception2.HTTPException(400, { message: "Invalid mounted path" });
+ const rawUrl = c.req.raw.url;
+ if (/^[^/?#]*\/\/[^/?#\\]*[?#\\]/.test(rawUrl)) throw new require_http_exception2.HTTPException(400, { message: "Invalid mounted path" });
+ const rawPathEnd = rawUrl.search(/[?#]/);
+ const rawTrailingSlashes = countTrailingSlashes(rawPathEnd === -1 ? rawUrl : rawUrl.slice(0, rawPathEnd));
+ if (rawTrailingSlashes > 1 && countTrailingSlashes(c.req.path) === rawTrailingSlashes - 1) throw new require_http_exception2.HTTPException(400, { message: "Invalid mounted path" });
+ const url = new URL(rawUrl);
+ url.pathname = path.replace(/[\\\t\n\r]/g, encodeURIComponent);
+ if (path.includes("%")) {
+ }
+ return new Request(url, c.req.raw);
+ };
+ exports2.createMountedRequest = createMountedRequest;
+ }
+});
+
+// node_modules/hono/dist/cjs/hono-base.js
+var require_hono_base = __commonJS({
+ "node_modules/hono/dist/cjs/hono-base.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_compose2 = require_compose();
+ var require_utils_url = require_url();
+ var require_context2 = require_context();
+ var require_helper_route_index = require_route();
+ var require_router5 = require_router();
+ var require_utils_constants = require_constants2();
+ var require_utils_mount = require_mount();
+ var notFoundHandler = (c) => {
+ return c.text("404 Not Found", 404);
+ };
+ var errorHandler = (err, c) => {
+ if ("getResponse" in err) {
+ const res = err.getResponse();
+ return c.newResponse(res.body, res);
+ }
+ console.error(err);
+ return c.text("Internal Server Error", 500);
+ };
+ var Hono = class Hono2 {
+ get;
+ post;
+ put;
+ delete;
+ options;
+ patch;
+ query;
+ all;
+ on;
+ use;
+ router;
+ getPath;
+ _basePath = "/";
+ #path = "/";
+ routes = [];
+ constructor(options = {}) {
+ [...require_router5.METHODS, "all"].forEach((method) => {
+ this[method] = (args1, ...args) => {
+ const methodName = method.toUpperCase();
+ if (typeof args1 === "string") this.#path = args1;
+ else this.#addRoute(methodName, this.#path, args1);
+ args.forEach((handler) => {
+ this.#addRoute(methodName, this.#path, handler);
+ });
+ return this;
+ };
+ });
+ this.on = (method, path, ...handlers) => {
+ for (const p of [path].flat()) {
+ this.#path = p;
+ for (const m of [method].flat()) {
+ const methodName = m.toUpperCase();
+ for (const handler of handlers) this.#addRoute(methodName, this.#path, handler);
+ }
+ }
+ return this;
+ };
+ this.use = (arg1, ...handlers) => {
+ if (typeof arg1 === "string") this.#path = arg1;
+ else {
+ this.#path = "*";
+ handlers.unshift(arg1);
+ }
+ handlers.forEach((handler) => {
+ this.#addRoute("ALL", this.#path, handler);
+ });
+ return this;
+ };
+ const { strict, ...optionsWithoutStrict } = options;
+ Object.assign(this, optionsWithoutStrict);
+ this.getPath = strict ?? true ? options.getPath ?? require_utils_url.getPath : require_utils_url.getPathNoStrict;
+ }
+ #clone() {
+ const clone = new Hono2({
+ router: this.router,
+ getPath: this.getPath
+ });
+ clone.errorHandler = this.errorHandler;
+ clone.#notFoundHandler = this.#notFoundHandler;
+ clone.routes = this.routes;
+ return clone;
+ }
+ #notFoundHandler = notFoundHandler;
+ errorHandler = errorHandler;
+ /**
+ * `.route()` allows grouping other Hono instance in routes.
+ *
+ * @see {@link https://hono.dev/docs/api/routing#grouping}
+ *
+ * @param {string} path - base Path
+ * @param {Hono} app - other Hono instance
+ * @returns {Hono} routed Hono instance
+ *
+ * @example
+ * ```ts
+ * const app = new Hono()
+ * const app2 = new Hono()
+ *
+ * app2.get("/user", (c) => c.text("user"))
+ * app.route("/api", app2) // GET /api/user
+ * ```
+ */
+ route(path, app) {
+ const subApp = this.basePath(path);
+ app.routes.map((r) => {
+ let handler;
+ if (app.errorHandler === errorHandler) handler = r.handler;
+ else {
+ handler = async (c, next) => (await require_compose2.compose([], app.errorHandler)(c, () => r.handler(c, next))).res;
+ handler[require_utils_constants.COMPOSED_HANDLER] = r.handler;
+ }
+ subApp.#addRoute(r.method, r.path, handler, r.basePath);
+ });
+ return this;
+ }
+ /**
+ * `.basePath()` allows base paths to be specified.
+ *
+ * @see {@link https://hono.dev/docs/api/routing#base-path}
+ *
+ * @param {string} path - base Path
+ * @returns {Hono} changed Hono instance
+ *
+ * @example
+ * ```ts
+ * const api = new Hono().basePath('/api')
+ * ```
+ */
+ basePath(path) {
+ const subApp = this.#clone();
+ subApp._basePath = require_utils_url.mergePath(this._basePath, path);
+ return subApp;
+ }
+ /**
+ * `.onError()` handles an error and returns a customized Response.
+ *
+ * @see {@link https://hono.dev/docs/api/hono#error-handling}
+ *
+ * @param {ErrorHandler} handler - request Handler for error
+ * @returns {Hono} changed Hono instance
+ *
+ * @example
+ * ```ts
+ * app.onError((err, c) => {
+ * console.error(`${err}`)
+ * return c.text('Custom Error Message', 500)
+ * })
+ * ```
+ */
+ onError = (handler) => {
+ this.errorHandler = handler;
+ return this;
+ };
+ /**
+ * `.notFound()` allows you to customize a Not Found Response.
+ *
+ * @see {@link https://hono.dev/docs/api/hono#not-found}
+ *
+ * @param {NotFoundHandler} handler - request handler for not-found
+ * @returns {Hono} changed Hono instance
+ *
+ * @example
+ * ```ts
+ * app.notFound((c) => {
+ * return c.text('Custom 404 Message', 404)
+ * })
+ * ```
+ */
+ notFound = (handler) => {
+ this.#notFoundHandler = handler;
+ return this;
+ };
+ /**
+ * `.mount()` allows you to mount applications built with other frameworks into your Hono application.
+ *
+ * @deprecated Use `mount()` from `hono/mount` instead. `.mount()` will be removed in v5.
+ *
+ * @see {@link https://hono.dev/docs/api/hono#mount}
+ *
+ * @param {string} path - base Path
+ * @param {Function} applicationHandler - other Request Handler
+ * @param {MountOptions} [options] - options of `.mount()`
+ * @returns {Hono} mounted Hono instance
+ * @throws {Error} If automatic request rewriting encounters duplicate path parameter names or an optional regular expression parameter.
+ * @throws {HTTPException} With status 400 if automatic request rewriting cannot safely preserve the routed path.
+ *
+ * @example
+ * ```ts
+ * import { Router as IttyRouter } from 'itty-router'
+ * import { Hono } from 'hono'
+ * // Create itty-router application
+ * const ittyRouter = IttyRouter()
+ * // GET /itty-router/hello
+ * ittyRouter.get('/hello', () => new Response('Hello from itty-router'))
+ *
+ * const app = new Hono()
+ * app.mount('/itty-router', ittyRouter.handle)
+ * ```
+ *
+ * @example
+ * ```ts
+ * const app = new Hono()
+ * // Send the request to another application without modification.
+ * app.mount('/app', anotherApp, {
+ * replaceRequest: (req) => req,
+ * })
+ * ```
+ */
+ mount(path, applicationHandler, options) {
+ let replaceRequest;
+ let optionHandler;
+ if (options) {
+ if (typeof options === "function") optionHandler = options;
+ else {
+ optionHandler = options.optionHandler;
+ if (options.replaceRequest === false) replaceRequest = (request) => request;
+ else replaceRequest = options.replaceRequest;
+ }
+ }
+ const getOptions = optionHandler ? (c) => {
+ const options2 = optionHandler(c);
+ return Array.isArray(options2) ? options2 : [options2];
+ } : (c) => {
+ let executionContext = void 0;
+ try {
+ executionContext = c.executionCtx;
+ } catch {
+ }
+ return [c.env, executionContext];
+ };
+ const defaultReplaceRequest = (c) => {
+ const mountedRoutePath = require_helper_route_index.routePath(c);
+ return require_utils_mount.createMountedRequest(c, mountedRoutePath.replace(/\/?\*$/, ""), mountedRoutePath);
+ };
+ const handler = async (c, next) => {
+ const res = await applicationHandler(replaceRequest ? replaceRequest(c.req.raw) : defaultReplaceRequest(c), ...getOptions(c));
+ if (res) return res;
+ await next();
+ };
+ this.#addRoute("ALL", require_utils_url.mergePath(path, "*"), handler);
+ return this;
+ }
+ #addRoute(method, path, handler, baseRoutePath) {
+ path = require_utils_url.mergePath(this._basePath, path);
+ const r = {
+ basePath: baseRoutePath !== void 0 ? require_utils_url.mergePath(this._basePath, baseRoutePath) : this._basePath,
+ path,
+ method,
+ handler
+ };
+ this.router.add(method, path, [handler, r]);
+ this.routes.push(r);
+ }
+ #handleError(err, c) {
+ if (err instanceof Error) return this.errorHandler(err, c);
+ throw err;
+ }
+ #dispatch(request, executionCtx, env, method) {
+ if (method === "HEAD") return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
+ const path = this.getPath(request, { env });
+ const matchResult = this.router.match(method, path);
+ const c = new require_context2.Context(request, {
+ path,
+ matchResult,
+ env,
+ executionCtx,
+ notFoundHandler: this.#notFoundHandler
+ });
+ if (matchResult[0].length === 1) {
+ let res;
+ try {
+ res = matchResult[0][0][0][0](c, async () => {
+ c.res = await this.#notFoundHandler(c);
+ });
+ } catch (err) {
+ return this.#handleError(err, c);
+ }
+ return res instanceof Promise ? res.then((resolved) => resolved || (c.finalized ? c.res : this.#notFoundHandler(c))).catch((err) => this.#handleError(err, c)) : res ?? this.#notFoundHandler(c);
+ }
+ const composed = require_compose2.compose(matchResult[0], this.errorHandler, this.#notFoundHandler);
+ return (async () => {
+ try {
+ const context = await composed(c);
+ if (!context.finalized) throw new Error("Context is not finalized. Did you forget to return a Response object or `await next()`?");
+ return context.res;
+ } catch (err) {
+ return this.#handleError(err, c);
+ }
+ })();
+ }
+ /**
+ * `.fetch()` will be entry point of your app.
+ *
+ * @see {@link https://hono.dev/docs/api/hono#fetch}
+ *
+ * @param {Request} request - request Object of request
+ * @param {Env} env - env Object
+ * @param {ExecutionContext} executionCtx - context of execution
+ * @returns {Response | Promise<Response>} response of request
+ *
+ */
+ fetch = (request, ...rest) => {
+ return this.#dispatch(request, rest[1], rest[0], request.method);
+ };
+ /**
+ * `.request()` is a useful method for testing.
+ * You can pass a URL or pathname to send a GET request.
+ * app will return a Response object.
+ * ```ts
+ * test('GET /hello is ok', async () => {
+ * const res = await app.request('/hello')
+ * expect(res.status).toBe(200)
+ * })
+ * ```
+ * @see https://hono.dev/docs/api/hono#request
+ */
+ request = (input, requestInit, Env, executionCtx) => {
+ if (input instanceof Request) return this.fetch(requestInit ? new Request(input, requestInit) : input, Env, executionCtx);
+ input = input.toString();
+ return this.fetch(new Request(/^https?:\/\//.test(input) ? input : `http://localhost${require_utils_url.mergePath("/", input)}`, requestInit), Env, executionCtx);
+ };
+ /**
+ * `.fire()` automatically adds a global fetch event listener.
+ * This can be useful for environments that adhere to the Service Worker API, such as non-ES module Cloudflare Workers.
+ * @deprecated
+ * Use `fire` from `hono/service-worker` instead.
+ * ```ts
+ * import { Hono } from 'hono'
+ * import { fire } from 'hono/service-worker'
+ *
+ * const app = new Hono()
+ * // ...
+ * fire(app)
+ * ```
+ * @see https://hono.dev/docs/api/hono#fire
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
+ * @see https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/
+ */
+ fire = () => {
+ addEventListener("fetch", (event) => {
+ event.respondWith(this.#dispatch(event.request, event, void 0, event.request.method));
+ });
+ };
+ };
+ exports2.HonoBase = Hono;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/utils.js
+var require_utils = __commonJS({
+ "node_modules/hono/dist/cjs/router/utils.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var createNullObject = () => /* @__PURE__ */ Object.create(null);
+ exports2.createNullObject = createNullObject;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/reg-exp-router/matcher.js
+var require_matcher = __commonJS({
+ "node_modules/hono/dist/cjs/router/reg-exp-router/matcher.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ require_router();
+ var emptyParam = [];
+ function match(method, path) {
+ const matchers = this.buildAllMatchers();
+ const match2 = (method2, path2) => {
+ const matcher = matchers[method2] || matchers["ALL"];
+ const staticMatch = matcher[2][path2];
+ if (staticMatch) return staticMatch;
+ const match3 = path2.match(matcher[0]);
+ if (!match3) return [[], emptyParam];
+ const index = match3.indexOf("", 1);
+ return [matcher[1][index], match3];
+ };
+ this.match = match2;
+ return match2(method, path);
+ }
+ exports2.emptyParam = emptyParam;
+ exports2.match = match;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/reg-exp-router/node.js
+var require_node = __commonJS({
+ "node_modules/hono/dist/cjs/router/reg-exp-router/node.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_router_utils = require_utils();
+ var LABEL_REG_EXP_STR = "[^/]+";
+ var ONLY_WILDCARD_REG_EXP_STR = "[^]*";
+ var TAIL_WILDCARD_REG_EXP_STR = "(?:|/[^]*)";
+ var PATH_ERROR = Symbol();
+ var regExpMetaChars = /* @__PURE__ */ new Set(".\\+*[^]$()");
+ function compareKey(a, b) {
+ if (a.length === 1) return b.length === 1 ? a < b ? -1 : 1 : -1;
+ if (b.length === 1) return 1;
+ if (a === "[^]*" || a === "(?:|/[^]*)") return b === "[^]*" ? -1 : 1;
+ else if (b === "[^]*" || b === "(?:|/[^]*)") return -1;
+ if (a === "[^/]+") return 1;
+ else if (b === "[^/]+") return -1;
+ return a.length === b.length ? a < b ? -1 : 1 : b.length - a.length;
+ }
+ var Node = class Node2 {
+ #index;
+ #varIndex;
+ #children = require_router_utils.createNullObject();
+ insert(tokens, index, paramMap, context, keys) {
+ let node = this;
+ for (let i = 0, len = tokens.length; i < len; i++) {
+ const token = tokens[i];
+ const pattern = token.length === 1 ? token === "*" ? i === len - 1 ? [
+ "",
+ "",
+ ONLY_WILDCARD_REG_EXP_STR
+ ] : [
+ "",
+ "",
+ LABEL_REG_EXP_STR
+ ] : null : token === "/*" ? [
+ "",
+ "",
+ TAIL_WILDCARD_REG_EXP_STR
+ ] : token.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/);
+ let nextNode;
+ if (pattern) {
+ const name = pattern[1];
+ let regexpStr = pattern[2] || "[^/]+";
+ if (name && pattern[2]) {
+ if (new RegExp(regexpStr).test("")) throw PATH_ERROR;
+ regexpStr = regexpStr.replace(/^\((?!\?:)(?=[^)]+\)$)/, "(?:");
+ if (/\((?!\?:)/.test(regexpStr)) throw PATH_ERROR;
+ if (regexpStr.length === 1 && regExpMetaChars.has(regexpStr)) throw PATH_ERROR;
+ }
+ keys.push(regexpStr);
+ nextNode = node.#children[regexpStr];
+ if (!nextNode) {
+ if (regexpStr !== "[^]*" && regexpStr !== "(?:|/[^]*)") {
+ for (const k in node.#children) if ((regexpStr.length > 1 || k.length > 1) && k !== "[^]*" && k !== "(?:|/[^]*)") throw PATH_ERROR;
+ }
+ nextNode = node.#children[regexpStr] = new Node2();
+ }
+ if (name !== "") {
+ nextNode.#varIndex ??= context.varIndex++;
+ paramMap.push([name, nextNode.#varIndex]);
+ }
+ } else {
+ keys.push(token);
+ nextNode = node.#children[token];
+ if (!nextNode) {
+ for (const k in node.#children) if (k.length > 1 && k !== "[^]*" && k !== "(?:|/[^]*)") throw PATH_ERROR;
+ nextNode = node.#children[token] = new Node2();
+ }
+ }
+ node = nextNode;
+ }
+ if (node.#index !== void 0) throw PATH_ERROR;
+ node.#index = index;
+ }
+ buildRegExpStr() {
+ const strList = Object.keys(this.#children).sort(compareKey).map((k) => {
+ const c = this.#children[k];
+ const childStr = c.buildRegExpStr();
+ return childStr === "" ? "" : (typeof c.#varIndex === "number" ? `(${k})@${c.#varIndex}` : regExpMetaChars.has(k) ? `\\${k}` : k) + childStr;
+ }).filter(Boolean);
+ if (typeof this.#index === "number" && this.#index !== -1) strList.unshift(`#${this.#index}`);
+ if (strList.length === 0) return "";
+ if (strList.length === 1) return strList[0];
+ return "(?:" + strList.join("|") + ")";
+ }
+ };
+ exports2.LABEL_REG_EXP_STR = LABEL_REG_EXP_STR;
+ exports2.Node = Node;
+ exports2.ONLY_WILDCARD_REG_EXP_STR = ONLY_WILDCARD_REG_EXP_STR;
+ exports2.PATH_ERROR = PATH_ERROR;
+ exports2.TAIL_WILDCARD_REG_EXP_STR = TAIL_WILDCARD_REG_EXP_STR;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/reg-exp-router/trie.js
+var require_trie = __commonJS({
+ "node_modules/hono/dist/cjs/router/reg-exp-router/trie.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_router_utils = require_utils();
+ var require_router_reg_exp_router_node = require_node();
+ var Trie = class {
+ #context = { varIndex: 0 };
+ #root = new require_router_reg_exp_router_node.Node();
+ #index = 0;
+ paths = require_router_utils.createNullObject();
+ insert(path, isStatic) {
+ const keys = [];
+ if (isStatic) {
+ this.#root.insert(path.split(""), -1, [], this.#context, keys);
+ this.paths[path] = [
+ -1,
+ [],
+ keys
+ ];
+ return;
+ }
+ const paramAssoc = [];
+ const groups = [];
+ let markedPath = path;
+ for (let i = 0; ; ) {
+ let replaced = false;
+ markedPath = markedPath.replace(/\{(?:\{[\d,]+\}|[^}])+\}/g, (m) => {
+ const mark = `@\\${i}`;
+ groups[i] = [mark, m];
+ i++;
+ replaced = true;
+ return mark;
+ });
+ if (!replaced) break;
+ }
+ const tokens = markedPath.match(/(?::[^\/]+)|(?:\/\*$)|./gs) || [];
+ for (let i = groups.length - 1; i >= 0; i--) {
+ const [mark] = groups[i];
+ for (let j = tokens.length - 1; j >= 0; j--) if (tokens[j].indexOf(mark) !== -1) {
+ tokens[j] = tokens[j].replace(mark, groups[i][1]);
+ break;
+ }
+ }
+ this.#root.insert(tokens, this.#index, paramAssoc, this.#context, keys);
+ this.paths[path] = [
+ this.#index++,
+ paramAssoc,
+ keys
+ ];
+ }
+ buildRegExp() {
+ let regexp = this.#root.buildRegExpStr();
+ if (regexp === "") return [
+ /^$/,
+ [],
+ []
+ ];
+ let captureIndex = 0;
+ const indexReplacementMap = [];
+ const paramReplacementMap = [];
+ regexp = regexp.replace(/#(\d+)|@(\d+)|\.\*\$/g, (_, handlerIndex, paramIndex) => {
+ if (handlerIndex !== void 0) {
+ indexReplacementMap[++captureIndex] = Number(handlerIndex);
+ return "$()";
+ }
+ if (paramIndex !== void 0) {
+ paramReplacementMap[Number(paramIndex)] = ++captureIndex;
+ return "";
+ }
+ return "";
+ });
+ return [
+ new RegExp(`^${regexp}`),
+ indexReplacementMap,
+ paramReplacementMap
+ ];
+ }
+ };
+ exports2.Trie = Trie;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/reg-exp-router/router.js
+var require_router2 = __commonJS({
+ "node_modules/hono/dist/cjs/router/reg-exp-router/router.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_utils_url = require_url();
+ var require_router5 = require_router();
+ var require_router_utils = require_utils();
+ var require_router_reg_exp_router_matcher = require_matcher();
+ var require_router_reg_exp_router_node = require_node();
+ var require_router_reg_exp_router_trie = require_trie();
+ function endsWithWildcard(keys) {
+ const tail = keys[keys.length - 1];
+ return tail === "[^]*" || tail === "(?:|/[^]*)";
+ }
+ function covers(wildcard, target) {
+ const last = wildcard.length - 1;
+ for (let i = 0; i < last; i++) if (wildcard[i] !== target[i]) return false;
+ return wildcard[last] === "[^]*" || target.length === last || target[last] === "/";
+ }
+ var RegExpRouter = class {
+ name = "RegExpRouter";
+ #handlers;
+ #tries;
+ constructor() {
+ this.#handlers = { ["ALL"]: [] };
+ this.#tries = { ["ALL"]: new require_router_reg_exp_router_trie.Trie() };
+ }
+ #insertPath(method, path) {
+ try {
+ this.#tries[method].insert(path, !/\*|\/:/.test(path));
+ } catch (e) {
+ throw e === require_router_reg_exp_router_node.PATH_ERROR ? new require_router5.UnsupportedPathError(path) : e;
+ }
+ }
+ add(method, path, handler) {
+ const handlers = this.#handlers;
+ if (!handlers) throw new Error(require_router5.MESSAGE_MATCHER_IS_ALREADY_BUILT);
+ const tries = this.#tries;
+ if (!handlers[method]) {
+ tries[method] = new require_router_reg_exp_router_trie.Trie();
+ handlers[method] = [...handlers["ALL"]];
+ for (const p in tries["ALL"].paths) this.#insertPath(method, p);
+ }
+ if (path === "/*") path = "*";
+ const methods = method === "ALL" ? Object.keys(handlers) : [method];
+ const paths = require_utils_url.checkOptionalParameter(path) || [path];
+ for (const path2 of paths) for (const m of methods) {
+ if (!tries[m].paths[path2]) this.#insertPath(m, path2);
+ handlers[m].push([handler, path2]);
+ }
+ }
+ match = require_router_reg_exp_router_matcher.match;
+ buildAllMatchers() {
+ const matchers = require_router_utils.createNullObject();
+ for (const method of Object.keys(this.#handlers)) matchers[method] = this.#buildMatcher(method);
+ this.#handlers = this.#tries = void 0;
+ return matchers;
+ }
+ #buildMatcher(method) {
+ const handlers = this.#handlers[method];
+ const trie = this.#tries[method];
+ const staticMap = require_router_utils.createNullObject();
+ const handlerData = [];
+ const [regexp, indexReplacementMap, paramReplacementMap] = trie.buildRegExp();
+ const own = require_router_utils.createNullObject();
+ const entries = handlers.map(([h, p], i) => {
+ (own[p] ??= []).push(i);
+ return [h, trie.paths[p][1].reduceRight((map, [key, varIndex]) => {
+ map[key] = paramReplacementMap[varIndex];
+ return map;
+ }, require_router_utils.createNullObject())];
+ });
+ const wildcards = Object.keys(own).filter((p) => endsWithWildcard(trie.paths[p][2]));
+ for (const path in trie.paths) {
+ const [index, , keys] = trie.paths[path];
+ const indexes = [...own[path]];
+ for (const p of wildcards) if (p !== path && covers(trie.paths[p][2], keys)) indexes.push(...own[p]);
+ const data = indexes.sort((a, b) => a - b).map((i) => entries[i]);
+ if (index === -1) {
+ const last = Math.max(-1, ...data.flatMap(([, map]) => Object.values(map)));
+ staticMap[path] = [data, last === -1 ? require_router_reg_exp_router_matcher.emptyParam : path.match(regexp).slice(0, last + 1)];
+ } else handlerData[index] = data;
+ }
+ return [
+ regexp,
+ indexReplacementMap.map((i) => handlerData[i]),
+ staticMap
+ ];
+ }
+ };
+ exports2.RegExpRouter = RegExpRouter;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/smart-router/router.js
+var require_router3 = __commonJS({
+ "node_modules/hono/dist/cjs/router/smart-router/router.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_router5 = require_router();
+ var SmartRouter = class {
+ name = "SmartRouter";
+ #routers = [];
+ #routes = [];
+ constructor(init) {
+ this.#routers = init.routers;
+ }
+ add(method, path, handler) {
+ if (!this.#routes) throw new Error(require_router5.MESSAGE_MATCHER_IS_ALREADY_BUILT);
+ this.#routes.push([
+ method,
+ path,
+ handler
+ ]);
+ }
+ match(method, path) {
+ if (!this.#routes) throw new Error("Fatal error");
+ const routers = this.#routers;
+ const routes = this.#routes;
+ const len = routers.length;
+ let i = 0;
+ let res;
+ for (; i < len; i++) {
+ const router = routers[i];
+ try {
+ for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) router.add(...routes[i2]);
+ res = router.match(method, path);
+ } catch (e) {
+ if (e instanceof require_router5.UnsupportedPathError) continue;
+ throw e;
+ }
+ this.match = router.match.bind(router);
+ this.#routers = [router];
+ this.#routes = void 0;
+ break;
+ }
+ if (i === len) throw new Error("Fatal error");
+ this.name = `SmartRouter + ${this.activeRouter.name}`;
+ return res;
+ }
+ get activeRouter() {
+ if (this.#routes || this.#routers.length !== 1) throw new Error("No active router has been determined yet.");
+ return this.#routers[0];
+ }
+ };
+ exports2.SmartRouter = SmartRouter;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/trie-router/node.js
+var require_node2 = __commonJS({
+ "node_modules/hono/dist/cjs/router/trie-router/node.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_utils_url = require_url();
+ require_router();
+ var require_router_utils = require_utils();
+ var emptyParams = require_router_utils.createNullObject();
+ var order = 0;
+ var Node = class Node2 {
+ #methods = [];
+ #children = require_router_utils.createNullObject();
+ #patterns = [];
+ #pattern;
+ #params = emptyParams;
+ insert(method, path, handler) {
+ let curNode = this;
+ const parts = require_utils_url.splitRoutingPath(path);
+ const possibleKeys = /* @__PURE__ */ new Set();
+ let i = 0;
+ for (const p of parts) {
+ const nextP = parts[++i];
+ const pattern = require_utils_url.getPattern(p, nextP) || (nextP === void 0 && p && p.indexOf("*") === p.length - 1 ? p : null);
+ const isParam = Array.isArray(pattern);
+ const key = isParam ? pattern[0] : pattern || p;
+ const child = curNode.#children[key] ||= new Node2();
+ if (pattern && !child.#pattern) {
+ child.#pattern = pattern;
+ curNode.#patterns.push(child);
+ }
+ curNode = child;
+ if (isParam) possibleKeys.add(pattern[1]);
+ }
+ curNode.#methods.push({ [method]: {
+ handler,
+ possibleKeys: [...possibleKeys],
+ score: ++order
+ } });
+ }
+ #pushHandlerSets(handlerSets, node, method, nodeParams, params) {
+ for (let i = 0, len = node.#methods.length; i < len; i++) {
+ const m = node.#methods[i];
+ const handlerSet = m[method] || m["ALL"];
+ if (handlerSet) {
+ handlerSet.params = require_router_utils.createNullObject();
+ handlerSets.push(handlerSet);
+ for (let i2 = 0, len2 = handlerSet.possibleKeys.length; i2 < len2; i2++) {
+ const key = handlerSet.possibleKeys[i2];
+ handlerSet.params[key] = params?.[key] && !i2 ? params[key] : nodeParams[key] ?? params?.[key];
+ }
+ }
+ }
+ }
+ search(method, path) {
+ const handlerSets = [];
+ this.#params = emptyParams;
+ let curNodes = [this];
+ const parts = require_utils_url.splitPath(path);
+ const curNodesQueue = [];
+ const len = parts.length;
+ let partOffsets = null;
+ for (let i = 0; i < len; i++) {
+ const part = parts[i];
+ const isLast = i === len - 1;
+ const tempNodes = [];
+ for (let j = 0, len2 = curNodes.length; j < len2; j++) {
+ const node = curNodes[j];
+ const nextNode = node.#children[part];
+ if (nextNode) {
+ nextNode.#params = node.#params;
+ if (isLast) {
+ if (nextNode.#children["*"]) this.#pushHandlerSets(handlerSets, nextNode.#children["*"], method, node.#params);
+ this.#pushHandlerSets(handlerSets, nextNode, method, node.#params);
+ } else tempNodes.push(nextNode);
+ }
+ for (const child of node.#patterns) {
+ const pattern = child.#pattern;
+ const params = node.#params === emptyParams ? {} : { ...node.#params };
+ if (typeof pattern === "string") {
+ if (pattern === "*" || part.startsWith(pattern.slice(0, -1))) {
+ this.#pushHandlerSets(handlerSets, child, method, node.#params);
+ if (pattern === "*") {
+ child.#params = params;
+ tempNodes.push(child);
+ }
+ }
+ continue;
+ }
+ const [, name, matcher] = pattern;
+ if (!part && matcher === true) continue;
+ if (matcher !== true) {
+ if (!partOffsets) {
+ partOffsets = [];
+ let offset = path[0] === "/" ? 1 : 0;
+ for (let p = 0; p < len; p++) {
+ partOffsets[p] = offset;
+ offset += parts[p].length + 1;
+ }
+ }
+ const restPathString = path.slice(partOffsets[i]);
+ const m = matcher.exec(restPathString);
+ if (m) {
+ if (!Object.hasOwn(params, name)) params[name] = m[0];
+ this.#pushHandlerSets(handlerSets, child, method, node.#params, params);
+ if (m[0].length === restPathString.length && child.#children["*"]) this.#pushHandlerSets(handlerSets, child.#children["*"], method, node.#params, params);
+ for (const _ in child.#children) {
+ child.#params = params;
+ const componentCount = m[0].match(/\//g)?.length ?? 0;
+ (curNodesQueue[componentCount] ||= []).push(child);
+ break;
+ }
+ continue;
+ }
+ }
+ if (matcher === true || matcher.test(part)) {
+ if (!Object.hasOwn(params, name)) params[name] = part;
+ if (isLast) {
+ this.#pushHandlerSets(handlerSets, child, method, params, node.#params);
+ if (child.#children["*"]) this.#pushHandlerSets(handlerSets, child.#children["*"], method, params, node.#params);
+ } else {
+ child.#params = params;
+ tempNodes.push(child);
+ }
+ }
+ }
+ }
+ const shifted = curNodesQueue.shift();
+ curNodes = shifted ? tempNodes.concat(shifted) : tempNodes;
+ }
+ if (handlerSets[1]) handlerSets.sort((a, b) => {
+ return a.score - b.score;
+ });
+ return [handlerSets.map(({ handler, params }) => [handler, params])];
+ }
+ };
+ exports2.Node = Node;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/trie-router/router.js
+var require_router4 = __commonJS({
+ "node_modules/hono/dist/cjs/router/trie-router/router.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_utils_url = require_url();
+ var require_router_trie_router_node = require_node2();
+ var TrieRouter = class {
+ name = "TrieRouter";
+ #node = new require_router_trie_router_node.Node();
+ add(method, path, handler) {
+ for (const result of require_utils_url.checkOptionalParameter(path) || [path]) this.#node.insert(method, result, handler);
+ }
+ match(method, path) {
+ return this.#node.search(method, path);
+ }
+ };
+ exports2.TrieRouter = TrieRouter;
+ }
+});
+
+// node_modules/hono/dist/cjs/router/trie-router/index.js
+var require_trie_router = __commonJS({
+ "node_modules/hono/dist/cjs/router/trie-router/index.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_router_trie_router_router = require_router4();
+ exports2.TrieRouter = require_router_trie_router_router.TrieRouter;
+ }
+});
+
+// node_modules/hono/dist/cjs/hono.js
+var require_hono = __commonJS({
+ "node_modules/hono/dist/cjs/hono.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_hono_base2 = require_hono_base();
+ var require_router_reg_exp_router_router = require_router2();
+ var require_router_smart_router_router = require_router3();
+ var require_router_trie_router_router = require_router4();
+ require_trie_router();
+ var Hono = class extends require_hono_base2.HonoBase {
+ /**
+ * Creates an instance of the Hono class.
+ *
+ * @param options - Optional configuration options for the Hono instance.
+ */
+ constructor(options = {}) {
+ super(options);
+ this.router = options.router ?? new require_router_smart_router_router.SmartRouter({ routers: [new require_router_reg_exp_router_router.RegExpRouter(), new require_router_trie_router_router.TrieRouter()] });
+ }
+ };
+ exports2.Hono = Hono;
+ }
+});
+
+// node_modules/hono/dist/cjs/index.js
+var require_cjs = __commonJS({
+ "node_modules/hono/dist/cjs/index.js"(exports2) {
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
+ var require_context2 = require_context();
+ var require_hono3 = require_hono();
+ exports2.Context = require_context2.Context;
+ exports2.Hono = require_hono3.Hono;
+ }
+});
+
+// node_modules/@hono/node-server/dist/index.js
+var require_dist = __commonJS({
+ "node_modules/@hono/node-server/dist/index.js"(exports2, module2) {
+ "use strict";
+ var __create2 = Object.create;
+ var __defProp2 = Object.defineProperty;
+ var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
+ var __getOwnPropNames2 = Object.getOwnPropertyNames;
+ var __getProtoOf2 = Object.getPrototypeOf;
+ var __hasOwnProp2 = Object.prototype.hasOwnProperty;
+ var __export = (target, all) => {
+ for (var name in all)
+ __defProp2(target, name, { get: all[name], enumerable: true });
+ };
+ var __copyProps2 = (to, from, except, desc) => {
+ if (from && typeof from === "object" || typeof from === "function") {
+ for (let key of __getOwnPropNames2(from))
+ if (!__hasOwnProp2.call(to, key) && key !== except)
+ __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
+ }
+ return to;
+ };
+ var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps2(
+ // If the importer is in node compatibility mode or this is not an ESM
+ // file that has been converted to a CommonJS file using a Babel-
+ // compatible transform (i.e. "__esModule" has not been set), then set
+ // "default" to the CommonJS "module.exports" for node compatibility.
+ isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target,
+ mod
+ ));
+ var __toCommonJS = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod);
+ var src_exports = {};
+ __export(src_exports, {
+ RequestError: () => RequestError,
+ createAdaptorServer: () => createAdaptorServer,
+ getRequestListener: () => getRequestListener,
+ serve: () => serve
+ });
+ module2.exports = __toCommonJS(src_exports);
+ var import_node_http = require("http");
+ var import_node_http22 = require("http2");
+ var import_node_http2 = require("http2");
+ var import_node_stream = require("stream");
+ var RequestError = class extends Error {
+ constructor(message, options) {
+ super(message, options);
+ this.name = "RequestError";
+ }
+ };
+ var validateRequestTarget = (target) => {
+ const backslash = target.indexOf("\\");
+ if (backslash !== -1) {
+ const query = target.indexOf("?");
+ if (query === -1 || backslash < query) {
+ throw new RequestError("Backslash in request path");
+ }
+ }
+ };
+ var toRequestError = (e) => {
+ if (e instanceof RequestError) {
+ return e;
+ }
+ return new RequestError(e.message, { cause: e });
+ };
+ var GlobalRequest = global.Request;
+ var Request2 = class extends GlobalRequest {
+ constructor(input, options) {
+ if (typeof input === "object" && getRequestCache in input) {
+ input = input[getRequestCache]();
+ }
+ if (typeof options?.body?.getReader !== "undefined") {
+ ;
+ options.duplex ??= "half";
+ }
+ super(input, options);
+ }
+ };
+ var newHeadersFromIncoming = (incoming) => {
+ const headerRecord = [];
+ const rawHeaders = incoming.rawHeaders;
+ for (let i = 0; i < rawHeaders.length; i += 2) {
+ const { [i]: key, [i + 1]: value } = rawHeaders;
+ if (key.charCodeAt(0) !== /*:*/
+ 58) {
+ headerRecord.push([key, value]);
+ }
+ }
+ return new Headers(headerRecord);
+ };
+ var wrapBodyStream = Symbol("wrapBodyStream");
+ var newRequestFromIncoming = (method, url, headers, incoming, abortController) => {
+ const init = {
+ method,
+ headers,
+ signal: abortController.signal
+ };
+ if (method === "TRACE") {
+ init.method = "GET";
+ const req = new Request2(url, init);
+ Object.defineProperty(req, "method", {
+ get() {
+ return "TRACE";
+ }
+ });
+ return req;
+ }
+ if (!(method === "GET" || method === "HEAD")) {
+ if ("rawBody" in incoming && incoming.rawBody instanceof Buffer) {
+ init.body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(incoming.rawBody);
+ controller.close();
+ }
+ });
+ } else if (incoming[wrapBodyStream]) {
+ let reader;
+ init.body = new ReadableStream({
+ async pull(controller) {
+ try {
+ reader ||= import_node_stream.Readable.toWeb(incoming).getReader();
+ const { done, value } = await reader.read();
+ if (done) {
+ controller.close();
+ } else {
+ controller.enqueue(value);
+ }
+ } catch (error) {
+ controller.error(error);
+ }
+ }
+ });
+ } else {
+ init.body = import_node_stream.Readable.toWeb(incoming);
+ }
+ }
+ return new Request2(url, init);
+ };
+ var getRequestCache = Symbol("getRequestCache");
+ var requestCache = Symbol("requestCache");
+ var incomingKey = Symbol("incomingKey");
+ var urlKey = Symbol("urlKey");
+ var headersKey = Symbol("headersKey");
+ var abortControllerKey = Symbol("abortControllerKey");
+ var getAbortController = Symbol("getAbortController");
+ var requestPrototype = {
+ get method() {
+ return this[incomingKey].method || "GET";
+ },
+ get url() {
+ return this[urlKey];
+ },
+ get headers() {
+ return this[headersKey] ||= newHeadersFromIncoming(this[incomingKey]);
+ },
+ [getAbortController]() {
+ this[getRequestCache]();
+ return this[abortControllerKey];
+ },
+ [getRequestCache]() {
+ this[abortControllerKey] ||= new AbortController();
+ return this[requestCache] ||= newRequestFromIncoming(
+ this.method,
+ this[urlKey],
+ this.headers,
+ this[incomingKey],
+ this[abortControllerKey]
+ );
+ }
+ };
+ [
+ "body",
+ "bodyUsed",
+ "cache",
+ "credentials",
+ "destination",
+ "integrity",
+ "mode",
+ "redirect",
+ "referrer",
+ "referrerPolicy",
+ "signal",
+ "keepalive"
+ ].forEach((k) => {
+ Object.defineProperty(requestPrototype, k, {
+ get() {
+ return this[getRequestCache]()[k];
+ }
+ });
+ });
+ ["arrayBuffer", "blob", "clone", "formData", "json", "text"].forEach((k) => {
+ Object.defineProperty(requestPrototype, k, {
+ value: function() {
+ return this[getRequestCache]()[k]();
+ }
+ });
+ });
+ Object.defineProperty(requestPrototype, Symbol.for("nodejs.util.inspect.custom"), {
+ value: function(depth, options, inspectFn) {
+ const props = {
+ method: this.method,
+ url: this.url,
+ headers: this.headers,
+ nativeRequest: this[requestCache]
+ };
+ return `Request (lightweight) ${inspectFn(props, { ...options, depth: depth == null ? null : depth - 1 })}`;
+ }
+ });
+ Object.setPrototypeOf(requestPrototype, Request2.prototype);
+ var newRequest = (incoming, defaultHostname) => {
+ const req = Object.create(requestPrototype);
+ req[incomingKey] = incoming;
+ const incomingUrl = incoming.url || "";
+ if (incomingUrl[0] !== "/" && // short-circuit for performance. most requests are relative URL.
+ (incomingUrl.startsWith("http://") || incomingUrl.startsWith("https://"))) {
+ if (incoming instanceof import_node_http2.Http2ServerRequest) {
+ throw new RequestError("Absolute URL for :path is not allowed in HTTP/2");
+ }
+ try {
+ validateRequestTarget(incomingUrl);
+ const url2 = new URL(incomingUrl);
+ req[urlKey] = url2.href;
+ } catch (e) {
+ throw new RequestError("Invalid absolute URL", { cause: e });
+ }
+ return req;
+ }
+ const host = (incoming instanceof import_node_http2.Http2ServerRequest ? incoming.authority : incoming.headers.host) || defaultHostname;
+ if (!host) {
+ throw new RequestError("Missing host header");
+ }
+ let scheme;
+ if (incoming instanceof import_node_http2.Http2ServerRequest) {
+ scheme = incoming.scheme;
+ if (!(scheme === "http" || scheme === "https")) {
+ throw new RequestError("Unsupported scheme");
+ }
+ } else {
+ scheme = incoming.socket && incoming.socket.encrypted ? "https" : "http";
+ }
+ validateRequestTarget(incomingUrl);
+ const url = new URL(`${scheme}://${host}${incomingUrl}`);
+ if (url.hostname.length !== host.length && url.hostname !== host.replace(/:\d+$/, "")) {
+ throw new RequestError("Invalid host header");
+ }
+ req[urlKey] = url.href;
+ return req;
+ };
+ var responseCache = Symbol("responseCache");
+ var getResponseCache = Symbol("getResponseCache");
+ var cacheKey = Symbol("cache");
+ var GlobalResponse = global.Response;
+ var Response2 = class _Response {
+ #body;
+ #init;
+ [getResponseCache]() {
+ delete this[cacheKey];
+ return this[responseCache] ||= new GlobalResponse(this.#body, this.#init);
+ }
+ constructor(body, init) {
+ let headers;
+ this.#body = body;
+ if (init instanceof _Response) {
+ const cachedGlobalResponse = init[responseCache];
+ if (cachedGlobalResponse) {
+ this.#init = cachedGlobalResponse;
+ this[getResponseCache]();
+ return;
+ } else {
+ this.#init = init.#init;
+ headers = new Headers(init.#init.headers);
+ }
+ } else {
+ this.#init = init;
+ }
+ if (typeof body === "string" || typeof body?.getReader !== "undefined" || body instanceof Blob || body instanceof Uint8Array) {
+ ;
+ this[cacheKey] = [init?.status || 200, body, headers || init?.headers];
+ }
+ }
+ get headers() {
+ const cache = this[cacheKey];
+ if (cache) {
+ if (!(cache[2] instanceof Headers)) {
+ cache[2] = new Headers(
+ cache[2] || { "content-type": "text/plain; charset=UTF-8" }
+ );
+ }
+ return cache[2];
+ }
+ return this[getResponseCache]().headers;
+ }
+ get status() {
+ return this[cacheKey]?.[0] ?? this[getResponseCache]().status;
+ }
+ get ok() {
+ const status = this.status;
+ return status >= 200 && status < 300;
+ }
+ };
+ ["body", "bodyUsed", "redirected", "statusText", "trailers", "type", "url"].forEach((k) => {
+ Object.defineProperty(Response2.prototype, k, {
+ get() {
+ return this[getResponseCache]()[k];
+ }
+ });
+ });
+ ["arrayBuffer", "blob", "clone", "formData", "json", "text"].forEach((k) => {
+ Object.defineProperty(Response2.prototype, k, {
+ value: function() {
+ return this[getResponseCache]()[k]();
+ }
+ });
+ });
+ Object.defineProperty(Response2.prototype, Symbol.for("nodejs.util.inspect.custom"), {
+ value: function(depth, options, inspectFn) {
+ const props = {
+ status: this.status,
+ headers: this.headers,
+ ok: this.ok,
+ nativeResponse: this[responseCache]
+ };
+ return `Response (lightweight) ${inspectFn(props, { ...options, depth: depth == null ? null : depth - 1 })}`;
+ }
+ });
+ Object.setPrototypeOf(Response2, GlobalResponse);
+ Object.setPrototypeOf(Response2.prototype, GlobalResponse.prototype);
+ async function readWithoutBlocking(readPromise) {
+ return Promise.race([readPromise, Promise.resolve().then(() => Promise.resolve(void 0))]);
+ }
+ function writeFromReadableStreamDefaultReader(reader, writable, currentReadPromise) {
+ const cancel = (error) => {
+ reader.cancel(error).catch(() => {
+ });
+ };
+ writable.on("close", cancel);
+ writable.on("error", cancel);
+ (currentReadPromise ?? reader.read()).then(flow, handleStreamError);
+ return reader.closed.finally(() => {
+ writable.off("close", cancel);
+ writable.off("error", cancel);
+ });
+ function handleStreamError(error) {
+ if (error) {
+ writable.destroy(error);
+ }
+ }
+ function onDrain() {
+ reader.read().then(flow, handleStreamError);
+ }
+ function flow({ done, value }) {
+ try {
+ if (done) {
+ writable.end();
+ } else if (!writable.write(value)) {
+ writable.once("drain", onDrain);
+ } else {
+ return reader.read().then(flow, handleStreamError);
+ }
+ } catch (e) {
+ handleStreamError(e);
+ }
+ }
+ }
+ function writeFromReadableStream(stream, writable) {
+ if (stream.locked) {
+ throw new TypeError("ReadableStream is locked.");
+ } else if (writable.destroyed) {
+ return;
+ }
+ return writeFromReadableStreamDefaultReader(stream.getReader(), writable);
+ }
+ var buildOutgoingHttpHeaders = (headers) => {
+ const res = {};
+ if (!(headers instanceof Headers)) {
+ headers = new Headers(headers ?? void 0);
+ }
+ const cookies = [];
+ for (const [k, v] of headers) {
+ if (k === "set-cookie") {
+ cookies.push(v);
+ } else {
+ res[k] = v;
+ }
+ }
+ if (cookies.length > 0) {
+ res["set-cookie"] = cookies;
+ }
+ res["content-type"] ??= "text/plain; charset=UTF-8";
+ return res;
+ };
+ var X_ALREADY_SENT = "x-hono-already-sent";
+ var import_node_crypto = __toESM2(require("crypto"));
+ if (typeof global.crypto === "undefined") {
+ global.crypto = import_node_crypto.default;
+ }
+ var outgoingEnded = Symbol("outgoingEnded");
+ var incomingDraining = Symbol("incomingDraining");
+ var DRAIN_TIMEOUT_MS = 500;
+ var MAX_DRAIN_BYTES = 64 * 1024 * 1024;
+ var drainIncoming = (incoming) => {
+ const incomingWithDrainState = incoming;
+ if (incoming.destroyed || incomingWithDrainState[incomingDraining]) {
+ return;
+ }
+ incomingWithDrainState[incomingDraining] = true;
+ if (incoming instanceof import_node_http22.Http2ServerRequest) {
+ try {
+ ;
+ incoming.stream?.close?.(import_node_http22.constants.NGHTTP2_NO_ERROR);
+ } catch {
+ }
+ return;
+ }
+ let bytesRead = 0;
+ const cleanup = () => {
+ clearTimeout(timer);
+ incoming.off("data", onData);
+ incoming.off("end", cleanup);
+ incoming.off("error", cleanup);
+ };
+ const forceClose = () => {
+ cleanup();
+ const socket = incoming.socket;
+ if (socket && !socket.destroyed) {
+ socket.destroySoon();
+ }
+ };
+ const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS);
+ timer.unref?.();
+ const onData = (chunk) => {
+ bytesRead += chunk.length;
+ if (bytesRead > MAX_DRAIN_BYTES) {
+ forceClose();
+ }
+ };
+ incoming.on("data", onData);
+ incoming.on("end", cleanup);
+ incoming.on("error", cleanup);
+ incoming.resume();
+ };
+ var handleRequestError = () => new Response(null, {
+ status: 400
+ });
+ var handleFetchError = (e) => new Response(null, {
+ status: e instanceof Error && (e.name === "TimeoutError" || e.constructor.name === "TimeoutError") ? 504 : 500
+ });
+ var handleResponseError = (e, outgoing) => {
+ const err = e instanceof Error ? e : new Error("unknown error", { cause: e });
+ if (err.code === "ERR_STREAM_PREMATURE_CLOSE") {
+ console.info("The user aborted a request.");
+ } else {
+ console.error(e);
+ if (!outgoing.headersSent) {
+ outgoing.writeHead(500, { "Content-Type": "text/plain" });
+ }
+ outgoing.end(`Error: ${err.message}`);
+ outgoing.destroy(err);
+ }
+ };
+ var flushHeaders = (outgoing) => {
+ if ("flushHeaders" in outgoing && outgoing.writable) {
+ outgoing.flushHeaders();
+ }
+ };
+ var responseViaCache = async (res, outgoing) => {
+ let [status, body, header] = res[cacheKey];
+ let hasContentLength = false;
+ if (!header) {
+ header = { "content-type": "text/plain; charset=UTF-8" };
+ } else if (header instanceof Headers) {
+ hasContentLength = header.has("content-length");
+ header = buildOutgoingHttpHeaders(header);
+ } else if (Array.isArray(header)) {
+ const headerObj = new Headers(header);
+ hasContentLength = headerObj.has("content-length");
+ header = buildOutgoingHttpHeaders(headerObj);
+ } else {
+ for (const key in header) {
+ if (key.length === 14 && key.toLowerCase() === "content-length") {
+ hasContentLength = true;
+ break;
+ }
+ }
+ }
+ if (!hasContentLength) {
+ if (typeof body === "string") {
+ header["Content-Length"] = Buffer.byteLength(body);
+ } else if (body instanceof Uint8Array) {
+ header["Content-Length"] = body.byteLength;
+ } else if (body instanceof Blob) {
+ header["Content-Length"] = body.size;
+ }
+ }
+ outgoing.writeHead(status, header);
+ if (typeof body === "string" || body instanceof Uint8Array) {
+ outgoing.end(body);
+ } else if (body instanceof Blob) {
+ outgoing.end(new Uint8Array(await body.arrayBuffer()));
+ } else {
+ flushHeaders(outgoing);
+ await writeFromReadableStream(body, outgoing)?.catch(
+ (e) => handleResponseError(e, outgoing)
+ );
+ }
+ ;
+ outgoing[outgoingEnded]?.();
+ };
+ var isPromise = (res) => typeof res.then === "function";
+ var responseViaResponseObject = async (res, outgoing, options = {}) => {
+ if (isPromise(res)) {
+ if (options.errorHandler) {
+ try {
+ res = await res;
+ } catch (err) {
+ const errRes = await options.errorHandler(err);
+ if (!errRes) {
+ return;
+ }
+ res = errRes;
+ }
+ } else {
+ res = await res.catch(handleFetchError);
+ }
+ }
+ if (cacheKey in res) {
+ return responseViaCache(res, outgoing);
+ }
+ const resHeaderRecord = buildOutgoingHttpHeaders(res.headers);
+ if (res.body) {
+ const reader = res.body.getReader();
+ const values = [];
+ let done = false;
+ let currentReadPromise = void 0;
+ if (resHeaderRecord["transfer-encoding"] !== "chunked") {
+ let maxReadCount = 2;
+ for (let i = 0; i < maxReadCount; i++) {
+ currentReadPromise ||= reader.read();
+ const chunk = await readWithoutBlocking(currentReadPromise).catch((e) => {
+ console.error(e);
+ done = true;
+ });
+ if (!chunk) {
+ if (i === 1) {
+ await new Promise((resolve) => setTimeout(resolve));
+ maxReadCount = 3;
+ continue;
+ }
+ break;
+ }
+ currentReadPromise = void 0;
+ if (chunk.value) {
+ values.push(chunk.value);
+ }
+ if (chunk.done) {
+ done = true;
+ break;
+ }
+ }
+ if (done && !("content-length" in resHeaderRecord)) {
+ resHeaderRecord["content-length"] = values.reduce((acc, value) => acc + value.length, 0);
+ }
+ }
+ outgoing.writeHead(res.status, resHeaderRecord);
+ values.forEach((value) => {
+ ;
+ outgoing.write(value);
+ });
+ if (done) {
+ outgoing.end();
+ } else {
+ if (values.length === 0) {
+ flushHeaders(outgoing);
+ }
+ await writeFromReadableStreamDefaultReader(reader, outgoing, currentReadPromise);
+ }
+ } else if (resHeaderRecord[X_ALREADY_SENT]) {
+ } else {
+ outgoing.writeHead(res.status, resHeaderRecord);
+ outgoing.end();
+ }
+ ;
+ outgoing[outgoingEnded]?.();
+ };
+ var getRequestListener = (fetchCallback, options = {}) => {
+ const autoCleanupIncoming = options.autoCleanupIncoming ?? true;
+ if (options.overrideGlobalObjects !== false && global.Request !== Request2) {
+ Object.defineProperty(global, "Request", {
+ value: Request2
+ });
+ Object.defineProperty(global, "Response", {
+ value: Response2
+ });
+ }
+ return async (incoming, outgoing) => {
+ let res, req;
+ try {
+ req = newRequest(incoming, options.hostname);
+ let incomingEnded = !autoCleanupIncoming || incoming.method === "GET" || incoming.method === "HEAD";
+ if (!incomingEnded) {
+ ;
+ incoming[wrapBodyStream] = true;
+ incoming.on("end", () => {
+ incomingEnded = true;
+ });
+ if (incoming instanceof import_node_http22.Http2ServerRequest) {
+ ;
+ outgoing[outgoingEnded] = () => {
+ if (!incomingEnded) {
+ setTimeout(() => {
+ if (!incomingEnded) {
+ setTimeout(() => {
+ drainIncoming(incoming);
+ });
+ }
+ });
+ }
+ };
+ }
+ outgoing.on("finish", () => {
+ if (!incomingEnded) {
+ drainIncoming(incoming);
+ }
+ });
+ }
+ outgoing.on("close", () => {
+ const abortController = req[abortControllerKey];
+ if (abortController) {
+ if (incoming.errored) {
+ req[abortControllerKey].abort(incoming.errored.toString());
+ } else if (!outgoing.writableFinished) {
+ req[abortControllerKey].abort("Client connection prematurely closed.");
+ }
+ }
+ if (!incomingEnded) {
+ setTimeout(() => {
+ if (!incomingEnded) {
+ setTimeout(() => {
+ drainIncoming(incoming);
+ });
+ }
+ });
+ }
+ });
+ res = fetchCallback(req, { incoming, outgoing });
+ if (cacheKey in res) {
+ return responseViaCache(res, outgoing);
+ }
+ } catch (e) {
+ if (!res) {
+ if (options.errorHandler) {
+ res = await options.errorHandler(req ? e : toRequestError(e));
+ if (!res) {
+ return;
+ }
+ } else if (!req) {
+ res = handleRequestError();
+ } else {
+ res = handleFetchError(e);
+ }
+ } else {
+ return handleResponseError(e, outgoing);
+ }
+ }
+ try {
+ return await responseViaResponseObject(res, outgoing, options);
+ } catch (e) {
+ return handleResponseError(e, outgoing);
+ }
+ };
+ };
+ var createAdaptorServer = (options) => {
+ const fetchCallback = options.fetch;
+ const requestListener = getRequestListener(fetchCallback, {
+ hostname: options.hostname,
+ overrideGlobalObjects: options.overrideGlobalObjects,
+ autoCleanupIncoming: options.autoCleanupIncoming
+ });
+ const createServer = options.createServer || import_node_http.createServer;
+ const server = createServer(options.serverOptions || {}, requestListener);
+ return server;
+ };
+ var serve = (options, listeningListener) => {
+ const server = createAdaptorServer(options);
+ server.listen(options?.port ?? 3e3, options.hostname, () => {
+ const serverInfo = server.address();
+ listeningListener && listeningListener(serverInfo);
+ });
+ return server;
+ };
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/hono/proxy.js
+var require_proxy = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/hono/proxy.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+ } : function(o, v) {
+ o["default"] = v;
+ });
+ var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ function() {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function(o2) {
+ var ar = [];
+ for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function(mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) {
+ for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ }
+ __setModuleDefault(result, mod);
+ return result;
+ };
+ }();
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ exports2.HonoProxy = void 0;
+ var pengueno_1 = require_dist2();
+ var AppLifetimeMetric = pengueno_1.Metric.fromName("HonoAppLifetime").asResult();
+ var AppRequestMetric = pengueno_1.Metric.fromName("HonoAppRequest");
+ var HonoProxy = class {
+ server;
+ app;
+ constructor(server) {
+ this.server = server;
+ }
+ async initializeApp() {
+ if (this.app)
+ return this.app;
+ const { Hono } = await Promise.resolve().then(() => __importStar(require_cjs()));
+ this.app = pengueno_1.LogMetricTraceable.of(new Hono()).flatMap(pengueno_1.TraceUtil.withTrace(`AppId = ${crypto.randomUUID()}`)).flatMap(pengueno_1.TraceUtil.withMetricTrace(AppLifetimeMetric));
+ return this.app;
+ }
+ async serve(port, hostname) {
+ const { serve } = await Promise.resolve().then(() => __importStar(require_dist()));
+ const app = await this.initializeApp();
+ return app.map((tApp) => pengueno_1.Either.fromFailable(() => {
+ const app2 = tApp.get();
+ app2.all("*", async (c) => tApp.flatMap(pengueno_1.TraceUtil.withMetricTrace(AppRequestMetric)).move(c.req).flatMap((tRequest) => pengueno_1.PenguenoRequest.from(tRequest)).map((req) => this.server.serve(req)).map(pengueno_1.TraceUtil.promiseify((tResponse) => {
+ tResponse.trace.trace(AppRequestMetric.count.withValue(1));
+ return new Response(tResponse.get().body(), tResponse.get());
+ })).get());
+ return serve({
+ fetch: (_r) => app2.fetch(_r),
+ port,
+ hostname
+ });
+ })).peek(pengueno_1.TraceUtil.traceResultingEither()).peek((tServe) => tServe.get().mapRight(() => tServe.trace.trace(`haii im still listening at http://${hostname}:${port} ~uwu dont think i forgot`))).map((tEitherServer) => tEitherServer.get().mapRight((server) => tEitherServer.move(server)).flatMapAsync((tServer) => pengueno_1.Signals.awaitClose(tServer))).peek(pengueno_1.TraceUtil.promiseify(pengueno_1.TraceUtil.traceResultingEither(AppLifetimeMetric))).get();
+ }
+ };
+ exports2.HonoProxy = HonoProxy;
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/hono/index.js
+var require_hono2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/hono/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_proxy(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/server/index.js
+var require_server = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/server/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_http(), exports2);
+ __exportStar(require_response(), exports2);
+ __exportStar(require_request(), exports2);
+ __exportStar(require_activity(), exports2);
+ __exportStar(require_filter(), exports2);
+ __exportStar(require_hono2(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/pengueno/dist/index.js
+var require_dist2 = __commonJS({
+ "node_modules/@emprespresso/pengueno/dist/index.js"(exports2) {
+ "use strict";
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() {
+ return m[k];
+ } };
+ }
+ Object.defineProperty(o, k2, desc);
+ } : function(o, m, k, k2) {
+ if (k2 === void 0) k2 = k;
+ o[k2] = m[k];
+ });
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
+ };
+ Object.defineProperty(exports2, "__esModule", { value: true });
+ __exportStar(require_leftpadesque(), exports2);
+ __exportStar(require_types(), exports2);
+ __exportStar(require_trace4(), exports2);
+ __exportStar(require_process(), exports2);
+ __exportStar(require_server(), exports2);
+ }
+});
+
+// node_modules/@emprespresso/ci_model/dist/job/index.js
+var import_pengueno = __toESM(require_dist2(), 1);
+var JobTypes = [
+ "fetch_code",
+ "ci_pipeline",
+ "build_docker_image.js",
+ "ansible_playbook.js",
+ "checkout_ci.js",
+ "npm_publish.js",
+ "coolify_webhook.js"
+];
+var isJobType = (j) => typeof j === "string" && JobTypes.includes(j);
+var isJob = (j) => !!((0, import_pengueno.isObject)(j) && "arguments" in j && (0, import_pengueno.isObject)(j.arguments) && "type" in j && isJobType(j.type) && j);
+
+// node_modules/@emprespresso/ci_model/dist/pipeline/index.js
+var import_pengueno3 = __toESM(require_dist2(), 1);
+
+// node_modules/@emprespresso/ci_model/dist/pipeline/builder.js
+var BasePipelineBuilder = class {
+ stages = [];
+ addStage(stage) {
+ this.stages.push(stage);
+ return this;
+ }
+ build() {
+ return new PipelineImpl(this.stages);
+ }
+};
+var DefaultGitHookPipelineBuilder = class extends BasePipelineBuilder {
+ remoteUrl;
+ rev;
+ refname;
+ constructor(remoteUrl = process.env.remote, rev = process.env.rev, refname = process.env.refname) {
+ super();
+ this.remoteUrl = remoteUrl;
+ this.rev = rev;
+ this.refname = refname;
+ this.addStage({
+ parallelJobs: [
+ {
+ type: "fetch_code",
+ arguments: {
+ remoteUrl,
+ checkout: rev,
+ path: this.getSourceDestination()
+ }
+ }
+ ]
+ });
+ }
+ getSourceDestination() {
+ return this.remoteUrl.replace(".git", "").split("/").at(-1) ?? "src";
+ }
+ getRev() {
+ return this.rev;
+ }
+ getBranch() {
+ const branchRefPrefix = "refs/heads/";
+ return this.refname.split(branchRefPrefix).at(1);
+ }
+};
+
+// node_modules/@emprespresso/ci_model/dist/pipeline/impl.js
+var import_pengueno2 = __toESM(require_dist2(), 1);
+var PipelineImpl = class _PipelineImpl {
+ serialJobs;
+ constructor(serialJobs) {
+ this.serialJobs = serialJobs;
+ }
+ serialize() {
+ return JSON.stringify({ serialJobs: this.serialJobs });
+ }
+ static from(s) {
+ return import_pengueno2.Either.fromFailable(() => JSON.parse(s)).flatMap((eitherPipelineJson) => isPipeline(eitherPipelineJson) ? import_pengueno2.Either.right(eitherPipelineJson) : import_pengueno2.Either.left(new Error("oh noes D: its a bad pipewine :(("))).mapRight((pipeline) => new _PipelineImpl(pipeline.serialJobs));
+ }
+};
+
+// node_modules/@emprespresso/ci_model/dist/pipeline/index.js
+var isPipelineStage = (t) => (0, import_pengueno3.isObject)(t) && "parallelJobs" in t && Array.isArray(t.parallelJobs) && t.parallelJobs.every((j) => isJob(j));
+var isPipeline = (t) => (0, import_pengueno3.isObject)(t) && "serialJobs" in t && Array.isArray(t.serialJobs) && t.serialJobs.every((p) => isPipelineStage(p));
+
+// dist/ci.js
+var REGISTRY = "img.liz.coffee";
+var NAMESPACE = "emprespresso";
+var IMG = "rookery";
+var getPipeline = () => {
+ const gitHookPipeline = new DefaultGitHookPipelineBuilder();
+ const branch = gitHookPipeline.getBranch();
+ if (!branch)
+ return gitHookPipeline.build();
+ const build = {
+ type: "build_docker_image.js",
+ arguments: {
+ context: gitHookPipeline.getSourceDestination(),
+ registry: REGISTRY,
+ namespace: NAMESPACE,
+ repository: IMG,
+ imageTag: branch,
+ commit: gitHookPipeline.getRev(),
+ buildTarget: IMG,
+ dockerfile: "Dockerfile"
+ }
+ };
+ gitHookPipeline.addStage({ parallelJobs: [build] });
+ return gitHookPipeline.build();
+};
+var main = () => {
+ const data = getPipeline().serialize();
+ process.stdout.write(data);
+};
+main();
diff --git a/.ci/ci.json b/.ci/ci.json
new file mode 100644
index 0000000..6a51028
--- /dev/null
+++ b/.ci/ci.json
@@ -0,0 +1,3 @@
+{
+ "workflow": ".ci/ci.cjs"
+}
diff --git a/.ci/ci.ts b/.ci/ci.ts
new file mode 100644
index 0000000..7908462
--- /dev/null
+++ b/.ci/ci.ts
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+
+import { BuildDockerImageJob, DefaultGitHookPipelineBuilder } from "@emprespresso/ci_model";
+
+const REGISTRY = "img.liz.coffee";
+const NAMESPACE = "emprespresso";
+const IMG = "rookery";
+
+/**
+ * Every push: build the Dockerfile's `rookery` target, which runs the whole test suite
+ * (`gradlew check`) and packages the desktop app. A failing test fails the image build.
+ */
+const getPipeline = () => {
+ const gitHookPipeline = new DefaultGitHookPipelineBuilder();
+ const branch = gitHookPipeline.getBranch();
+ if (!branch) return gitHookPipeline.build();
+
+ const build: BuildDockerImageJob = {
+ type: "build_docker_image.js",
+ arguments: {
+ context: gitHookPipeline.getSourceDestination(),
+ registry: REGISTRY,
+ namespace: NAMESPACE,
+ repository: IMG,
+ imageTag: branch,
+ commit: gitHookPipeline.getRev(),
+ buildTarget: IMG,
+ dockerfile: "Dockerfile",
+ },
+ };
+ gitHookPipeline.addStage({ parallelJobs: [build] });
+
+ return gitHookPipeline.build();
+};
+
+const main = () => {
+ const data = getPipeline().serialize();
+ process.stdout.write(data);
+};
+
+main();
diff --git a/.ci/package-lock.json b/.ci/package-lock.json
new file mode 100644
index 0000000..c6a2ce7
--- /dev/null
+++ b/.ci/package-lock.json
@@ -0,0 +1,920 @@
+{
+ "name": ".ci",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@emprespresso/ci_model": "^0.4.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.3",
+ "esbuild": "0.25.5",
+ "typescript": "^7.0.2"
+ }
+ },
+ "node_modules/@emprespresso/ci_model": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emprespresso/ci_model/-/ci_model-0.4.0.tgz",
+ "integrity": "sha512-pdX0LngjzuRtTQLgu1koKjdrAxpBI003XUM6ryVNeCF71tJjEjGAbOGOXoqRjkLgHWWA8uks3rSbk1OrcPFa+A==",
+ "dependencies": {
+ "@emprespresso/pengueno": "^0.0.17"
+ }
+ },
+ "node_modules/@emprespresso/pengueno": {
+ "version": "0.0.17",
+ "resolved": "https://registry.npmjs.org/@emprespresso/pengueno/-/pengueno-0.0.17.tgz",
+ "integrity": "sha512-pyfXLYGoLlQlMQAyYptRLLiRrnQmCju4I1/sv1KtsJv06oPvNqzHqUzJJGHrXg/90krg7GRcxLKQ0TnQ2Bskjw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.16.0",
+ "npm": ">=10.0.0"
+ },
+ "optionalDependencies": {
+ "@hono/node-server": "^1.18.2",
+ "hono": "^4.8.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
+ "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
+ "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
+ "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
+ "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
+ "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
+ "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
+ "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
+ "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
+ "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
+ "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
+ "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
+ "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
+ "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
+ "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
+ "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
+ "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
+ "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
+ "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
+ "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
+ "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
+ "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
+ "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
+ "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
+ "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
+ "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.19",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.19.tgz",
+ "integrity": "sha512-Fy9R7PACOzHU9LDpfnuzVyuVYevKQRcEG2mZhmfc+DRdmayEd3RVfa2XOt2IgGcPNgA77Yk63KBszepcMFK5Ng==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
+ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.5",
+ "@esbuild/android-arm": "0.25.5",
+ "@esbuild/android-arm64": "0.25.5",
+ "@esbuild/android-x64": "0.25.5",
+ "@esbuild/darwin-arm64": "0.25.5",
+ "@esbuild/darwin-x64": "0.25.5",
+ "@esbuild/freebsd-arm64": "0.25.5",
+ "@esbuild/freebsd-x64": "0.25.5",
+ "@esbuild/linux-arm": "0.25.5",
+ "@esbuild/linux-arm64": "0.25.5",
+ "@esbuild/linux-ia32": "0.25.5",
+ "@esbuild/linux-loong64": "0.25.5",
+ "@esbuild/linux-mips64el": "0.25.5",
+ "@esbuild/linux-ppc64": "0.25.5",
+ "@esbuild/linux-riscv64": "0.25.5",
+ "@esbuild/linux-s390x": "0.25.5",
+ "@esbuild/linux-x64": "0.25.5",
+ "@esbuild/netbsd-arm64": "0.25.5",
+ "@esbuild/netbsd-x64": "0.25.5",
+ "@esbuild/openbsd-arm64": "0.25.5",
+ "@esbuild/openbsd-x64": "0.25.5",
+ "@esbuild/sunos-x64": "0.25.5",
+ "@esbuild/win32-arm64": "0.25.5",
+ "@esbuild/win32-ia32": "0.25.5",
+ "@esbuild/win32-x64": "0.25.5"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.18",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.18.tgz",
+ "integrity": "sha512-H9q/E1XizaWstJxUW881dmLxEj+t/TrGGnyxINkm4+sK0qUwsMtFdo2BWI9Vs3cIS19MU6b6iCijoUoEf4f3DA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/.ci/package.json b/.ci/package.json
new file mode 100644
index 0000000..e71148e
--- /dev/null
+++ b/.ci/package.json
@@ -0,0 +1,18 @@
+{
+ "scripts": {
+ "build": "tsc && esbuild --format=cjs --target=node22 --platform=node --bundle --outfile=ci.cjs dist/ci.js && chmod +x ci.cjs",
+ "clean": "rm -rf dist ci.cjs"
+ },
+ "dependencies": {
+ "@emprespresso/ci_model": "^0.4.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.3",
+ "esbuild": "0.25.5",
+ "typescript": "^7.0.2"
+ },
+ "files": [
+ "dist/**/*",
+ "package.json"
+ ]
+}
diff --git a/.ci/tsconfig.json b/.ci/tsconfig.json
new file mode 100644
index 0000000..6e44f85
--- /dev/null
+++ b/.ci/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "rootDir": "./",
+ "composite": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "noEmit": false,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": ["node"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d02da12
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+.gradle
+.kotlin
+**/build
+.ci/node_modules
+.ci/dist
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f70c7a8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.gradle/
+build/
+.kotlin/
+.idea/
+*.iml
+local.properties
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b65f8e2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+# Build + test stage: a failing test fails the image build, which is what lizCI checks.
+FROM eclipse-temurin:21-jdk AS build
+WORKDIR /src
+# Warm the Gradle wrapper and dependency cache before copying sources.
+COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
+COPY gradle gradle
+COPY core/build.gradle.kts core/
+COPY ui/build.gradle.kts ui/
+COPY desktop/build.gradle.kts desktop/
+RUN ./gradlew --no-daemon -q help
+COPY . .
+RUN ./gradlew --no-daemon check :desktop:createDistributable
+
+# Artifact image: just the packaged Linux desktop app (with its bundled runtime).
+FROM alpine:3.20 AS rookery
+ARG COMMIT=unknown
+COPY --from=build /src/desktop/build/compose/binaries/main/app/rookery /opt/rookery
+RUN printf '%s' "$COMMIT" > /opt/rookery/commit
+CMD ["ls", "/opt/rookery"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..50433c3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 the rookery authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..1efb0bc
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.kotlin.multiplatform) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.compose) apply false
+}
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 0000000..718fdaa
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+kotlin {
+ jvmToolchain(21)
+ jvm()
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.coroutines.core)
+ api(libs.serialization.json)
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.coroutines.test)
+ }
+ jvmMain.dependencies {
+ implementation(libs.tnoodle.scrambles)
+ implementation(libs.dbus.java.core)
+ implementation(libs.dbus.java.transport.native)
+ }
+ }
+}
+
+// Same heap cap as the desktop app, so tests catch scramblers that outgrow it.
+tasks.withType<Test>().configureEach { maxHeapSize = "256m" }
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt
new file mode 100644
index 0000000..954e423
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt
@@ -0,0 +1,13 @@
+package coffee.liz.rookery
+
+/** Wall-clock time in Unix epoch milliseconds. */
+expect fun epochMillis(): Long
+
+/** Monotonic milliseconds for measuring durations; never goes backwards. */
+expect fun monoMillis(): Long
+
+/** A random identifier, unique enough to name devices and new entities. */
+expect fun randomId(): String
+
+/** Mutual exclusion on [lock] (reentrant). */
+expect fun <T> locked(lock: Any, block: () -> T): T
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt
new file mode 100644
index 0000000..6fe59f9
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt
@@ -0,0 +1,69 @@
+package coffee.liz.rookery.audio
+
+import kotlin.math.PI
+import kotlin.math.exp
+import kotlin.math.pow
+import kotlin.math.sin
+
+/**
+ * A tiny 8-bit synth: pulse and triangle voices with a plucky envelope, rendered to 16-bit mono PCM.
+ * Generated in code so there's no audio asset to ship or license.
+ */
+object Chime {
+ const val SAMPLE_RATE = 44_100
+
+ enum class Voice { PULSE25, PULSE50, TRIANGLE }
+
+ /** A note: MIDI pitch (null = rest), start and length in sixteenth notes. */
+ data class Note(val midi: Int?, val start: Int, val len: Int, val voice: Voice = Voice.PULSE25, val vol: Double = 0.5)
+
+ /** The single-PB jingle: a rising arpeggio and a sparkly resolution. */
+ val PB = listOf(
+ Note(72, 0, 1), Note(76, 1, 1), Note(79, 2, 1), Note(84, 3, 3, Voice.PULSE50),
+ Note(83, 6, 1), Note(84, 7, 4, Voice.PULSE50),
+ Note(48, 0, 3, Voice.TRIANGLE, 0.6), Note(55, 3, 3, Voice.TRIANGLE, 0.6), Note(60, 6, 5, Voice.TRIANGLE, 0.6),
+ Note(91, 8, 1, Voice.PULSE25, 0.25), Note(96, 9, 2, Voice.PULSE25, 0.2),
+ )
+
+ /** Longer fanfare for average PBs. */
+ val AVERAGE_PB = listOf(
+ Note(67, 0, 1), Note(72, 1, 1), Note(76, 2, 1), Note(79, 3, 2),
+ Note(76, 5, 1), Note(79, 6, 4, Voice.PULSE50),
+ Note(77, 10, 1), Note(81, 11, 1), Note(84, 12, 1), Note(89, 13, 5, Voice.PULSE50),
+ Note(43, 0, 5, Voice.TRIANGLE, 0.6), Note(48, 5, 5, Voice.TRIANGLE, 0.6), Note(53, 10, 3, Voice.TRIANGLE, 0.6), Note(48, 13, 5, Voice.TRIANGLE, 0.6),
+ Note(96, 14, 1, Voice.PULSE25, 0.2), Note(100, 15, 1, Voice.PULSE25, 0.2), Note(103, 16, 2, Voice.PULSE25, 0.2),
+ )
+
+ /** Short descending blip for a DNF. */
+ val DNF = listOf(Note(64, 0, 1, Voice.PULSE50, 0.35), Note(57, 1, 2, Voice.PULSE50, 0.35))
+
+ fun render(notes: List<Note>, bpm: Int = 150, volume: Double = 0.35): ShortArray {
+ val sixteenth = SAMPLE_RATE * 60.0 / bpm / 4
+ val total = ((notes.maxOf { it.start + it.len } + 2) * sixteenth).toInt()
+ val mix = DoubleArray(total)
+ for (note in notes) {
+ val midi = note.midi ?: continue
+ val freq = 440.0 * 2.0.pow((midi - 69) / 12.0)
+ val start = (note.start * sixteenth).toInt()
+ val len = (note.len * sixteenth).toInt()
+ val release = (0.04 * SAMPLE_RATE).toInt()
+ for (i in 0 until len + release) {
+ val idx = start + i
+ if (idx >= total) break
+ val phase = (i * freq / SAMPLE_RATE) % 1.0
+ val wave = when (note.voice) {
+ Voice.PULSE25 -> if (phase < 0.25) 1.0 else -1.0
+ Voice.PULSE50 -> if (phase < 0.5) 1.0 else -1.0
+ Voice.TRIANGLE -> 4 * kotlin.math.abs(phase - 0.5) - 1
+ }
+ val attack = minOf(1.0, i / (0.004 * SAMPLE_RATE))
+ val decay = exp(-i / (len * 0.9 + 1))
+ val tail = if (i < len) 1.0 else 1.0 - (i - len).toDouble() / release
+ // a dash of vibrato on long notes, NES-style
+ val vib = if (i > SAMPLE_RATE / 10) 1 + 0.003 * sin(2 * PI * 6 * i / SAMPLE_RATE) else 1.0
+ mix[idx] += wave * vib * note.vol * attack * (0.35 + 0.65 * decay) * tail
+ }
+ }
+ return ShortArray(total) { i -> (mix[i] * volume * Short.MAX_VALUE).coerceIn(-32767.0, 32767.0).toInt().toShort() }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt
new file mode 100644
index 0000000..7db19a8
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt
@@ -0,0 +1,57 @@
+package coffee.liz.rookery.bt
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * One advertisement / discovery result. [manufacturerData] maps company id (16-bit) to its payload.
+ * UUIDs are lower-case 128-bit strings.
+ */
+data class BleAdvertisement(
+ val address: String,
+ val name: String?,
+ val rssi: Int?,
+ val manufacturerData: Map<Int, ByteArray> = emptyMap(),
+ val serviceUuids: List<String> = emptyList(),
+)
+
+data class BleCharacteristic(val uuid: String, val properties: Set<String> = emptySet())
+
+data class BleService(val uuid: String, val characteristics: List<BleCharacteristic>)
+
+/**
+ * Minimal platform-neutral BLE central API (BlueZ on Linux, BluetoothGatt on Android later).
+ * All UUIDs are lower-case 128-bit strings.
+ */
+interface BleTransport {
+ /** Scans until the collector cancels. May emit the same device repeatedly as RSSI updates. */
+ fun scan(): Flow<BleAdvertisement>
+
+ suspend fun connect(address: String): BleConnection
+}
+
+interface BleConnection {
+ val address: String
+ val name: String?
+
+ suspend fun discoverServices(): List<BleService>
+ suspend fun read(service: String, characteristic: String): ByteArray
+ /** Writes a value; the transport picks write-with-response when the characteristic supports it. */
+ suspend fun write(service: String, characteristic: String, value: ByteArray)
+
+ /**
+ * Enables notifications and returns once they are on. Values arriving from then on are buffered
+ * until collected (single collector); the flow completes when the connection closes.
+ */
+ suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray>
+
+ /** Emits once when the link drops, whether by [disconnect] or remotely. */
+ val disconnected: Flow<Unit>
+
+ suspend fun disconnect()
+}
+
+/** Raw AES-128 single-block primitive so the protocol code stays pure common Kotlin. */
+interface Aes128 {
+ fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray
+ fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt
new file mode 100644
index 0000000..a67563e
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt
@@ -0,0 +1,108 @@
+// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License
+// (https://github.com/afedotov/gan-web-bluetooth).
+package coffee.liz.rookery.bt
+
+/** GATT UUIDs and key material for GAN smart cubes. */
+object GanDefinitions {
+ const val GEN2_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dc4179"
+ const val GEN2_COMMAND = "28be4a4a-cd67-11e9-a32f-2a2ae2dbcce4"
+ const val GEN2_STATE = "28be4cb6-cd67-11e9-a32f-2a2ae2dbcce4"
+
+ const val GEN3_SERVICE = "8653000a-43e6-47b7-9cb0-5fc21d4ae340"
+ const val GEN3_COMMAND = "8653000c-43e6-47b7-9cb0-5fc21d4ae340"
+ const val GEN3_STATE = "8653000b-43e6-47b7-9cb0-5fc21d4ae340"
+
+ const val GEN4_SERVICE = "00000010-0000-fff7-fff6-fff5fff4fff0"
+ const val GEN4_COMMAND = "0000fff5-0000-1000-8000-00805f9b34fb"
+ const val GEN4_STATE = "0000fff6-0000-1000-8000-00805f9b34fb"
+
+ /** Advertised name prefixes of GAN-protocol cubes (MoYu AI 2023 = "AiCube" uses Gen2 with its own key). */
+ val NAME_PREFIXES = listOf("GAN", "MG", "AiCube")
+
+ /** Company identifier codes GAN cubes use: 0x0001, 0x0101, ... 0xFF01. */
+ fun isGanCic(cic: Int): Boolean = (cic and 0xFF) == 0x01
+
+ class KeyIv(val key: ByteArray, val iv: ByteArray)
+
+ private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() }
+
+ /** Key used by GAN Gen2, Gen3 and Gen4 cubes. */
+ val GAN_KEY = KeyIv(
+ bytes(0x01, 0x02, 0x42, 0x28, 0x31, 0x91, 0x16, 0x07, 0x20, 0x05, 0x18, 0x54, 0x42, 0x11, 0x12, 0x53),
+ bytes(0x11, 0x03, 0x32, 0x28, 0x21, 0x01, 0x76, 0x27, 0x20, 0x95, 0x78, 0x14, 0x32, 0x12, 0x02, 0x43),
+ )
+
+ /** Key used by MoYu AI 2023 (advertises as "AiCube", speaks GAN Gen2). */
+ val MOYU_AI_KEY = KeyIv(
+ bytes(0x05, 0x12, 0x02, 0x45, 0x02, 0x01, 0x29, 0x56, 0x12, 0x78, 0x12, 0x76, 0x81, 0x01, 0x08, 0x03),
+ bytes(0x01, 0x44, 0x28, 0x06, 0x86, 0x21, 0x22, 0x28, 0x51, 0x05, 0x08, 0x31, 0x82, 0x02, 0x21, 0x06),
+ )
+
+ /** Encryption salt: the 6 MAC bytes in reverse order. Accepts "AA:BB:...", "AA-BB-..." or spaces. */
+ fun saltFromMac(mac: String): ByteArray {
+ val parts = mac.trim().split(Regex("[:\\-\\s]+"))
+ require(parts.size == 6) { "MAC must have 6 bytes: $mac" }
+ return ByteArray(6) { parts[5 - it].toInt(16).toByte() }
+ }
+
+ /**
+ * The cube's MAC from GAN manufacturer-specific data: the last 6 of the first 9 payload bytes, reversed.
+ * Returns null if no GAN company id is present.
+ */
+ fun macFromManufacturerData(data: Map<Int, ByteArray>): String? {
+ val payload = data.entries.firstOrNull { isGanCic(it.key) }?.value ?: return null
+ val view = payload.copyOfRange(0, minOf(9, payload.size))
+ if (view.size < 6) return null
+ return (1..6).joinToString(":") { i -> (view[view.size - i].toInt() and 0xFF).toString(16).uppercase().padStart(2, '0') }
+ }
+}
+
+/**
+ * GAN's message encryption (identical for Gen2/3/4): AES-128-CBC with a fresh IV on each 16-byte chunk,
+ * applied to the chunk at the start of the message and then to the (possibly overlapping) chunk at its end.
+ * Key and IV are salted with the reversed MAC: `k[i] = (k[i] + salt[i]) % 0xFF` for i < 6
+ * (sic: modulo 255, which is what the cube firmware does).
+ */
+class GanEncrypter(private val aes: Aes128, baseKey: ByteArray, baseIv: ByteArray, salt: ByteArray) {
+ val key: ByteArray = baseKey.copyOf()
+ val iv: ByteArray = baseIv.copyOf()
+
+ init {
+ require(baseKey.size == 16) { "Key must be 16 bytes (128-bit) long" }
+ require(baseIv.size == 16) { "Iv must be 16 bytes (128-bit) long" }
+ require(salt.size == 6) { "Salt must be 6 bytes (48-bit) long" }
+ for (i in 0 until 6) {
+ key[i] = (((baseKey[i].toInt() and 0xFF) + (salt[i].toInt() and 0xFF)) % 0xFF).toByte()
+ iv[i] = (((baseIv[i].toInt() and 0xFF) + (salt[i].toInt() and 0xFF)) % 0xFF).toByte()
+ }
+ }
+
+ constructor(aes: Aes128, keyIv: GanDefinitions.KeyIv, mac: String) :
+ this(aes, keyIv.key, keyIv.iv, GanDefinitions.saltFromMac(mac))
+
+ private fun encryptChunk(buf: ByteArray, off: Int) {
+ val block = ByteArray(16) { (buf[off + it].toInt() xor iv[it].toInt()).toByte() }
+ aes.encryptBlock(key, block).copyInto(buf, off)
+ }
+
+ private fun decryptChunk(buf: ByteArray, off: Int) {
+ val plain = aes.decryptBlock(key, buf.copyOfRange(off, off + 16))
+ for (i in 0 until 16) buf[off + i] = (plain[i].toInt() xor iv[i].toInt()).toByte()
+ }
+
+ fun encrypt(data: ByteArray): ByteArray {
+ require(data.size >= 16) { "Data must be at least 16 bytes long" }
+ val res = data.copyOf()
+ encryptChunk(res, 0)
+ if (res.size > 16) encryptChunk(res, res.size - 16)
+ return res
+ }
+
+ fun decrypt(data: ByteArray): ByteArray {
+ require(data.size >= 16) { "Data must be at least 16 bytes long" }
+ val res = data.copyOf()
+ if (res.size > 16) decryptChunk(res, res.size - 16)
+ decryptChunk(res, 0)
+ return res
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt
new file mode 100644
index 0000000..c6991d4
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt
@@ -0,0 +1,139 @@
+// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License
+// (https://github.com/afedotov/gan-web-bluetooth).
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.monoMillis
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+enum class GanGeneration(val service: String, val command: String, val state: String) {
+ GEN2(GanDefinitions.GEN2_SERVICE, GanDefinitions.GEN2_COMMAND, GanDefinitions.GEN2_STATE),
+ GEN3(GanDefinitions.GEN3_SERVICE, GanDefinitions.GEN3_COMMAND, GanDefinitions.GEN3_STATE),
+ GEN4(GanDefinitions.GEN4_SERVICE, GanDefinitions.GEN4_COMMAND, GanDefinitions.GEN4_STATE);
+
+ companion object {
+ fun fromServiceUuids(uuids: Collection<String>): GanGeneration? {
+ val lower = uuids.map { it.lowercase() }.toSet()
+ return entries.firstOrNull { it.service in lower }
+ }
+ }
+}
+
+/** Connects to GAN smart cubes over a [BleTransport]. */
+class GanCubeConnector(
+ private val transport: BleTransport,
+ private val aes: Aes128,
+ private val clock: () -> Long = ::monoMillis,
+) {
+ /**
+ * Connects to [address], detects the protocol generation from its GATT services, turns on notifications
+ * and returns the cube. [mac] (salt for the encryption key) defaults to [address]; pass it explicitly if the
+ * platform exposes a different address than the cube's real MAC. Events are processed in [scope].
+ */
+ suspend fun connect(scope: CoroutineScope, address: String, mac: String = address): SmartCube {
+ val conn = transport.connect(address)
+ try {
+ val services = conn.discoverServices()
+ val gen = GanGeneration.fromServiceUuids(services.map { it.uuid })
+ ?: throw IllegalStateException("No GAN cube service found on $address - wrong or unsupported cube model")
+ val keyIv = if (gen == GanGeneration.GEN2 && conn.name?.startsWith("AiCube") == true) GanDefinitions.MOYU_AI_KEY else GanDefinitions.GAN_KEY
+ val encrypter = GanEncrypter(aes, keyIv, mac)
+ val driver = when (gen) {
+ GanGeneration.GEN2 -> GanGen2ProtocolDriver(clock)
+ GanGeneration.GEN3 -> GanGen3ProtocolDriver(clock)
+ GanGeneration.GEN4 -> GanGen4ProtocolDriver(clock)
+ }
+ val notifications = conn.subscribe(gen.service, gen.state)
+ return GanSmartCube(scope, conn, gen, encrypter, driver, notifications)
+ } catch (e: Throwable) {
+ withContext(NonCancellable) { runCatching { conn.disconnect() } }
+ throw e
+ }
+ }
+
+ companion object {
+ /**
+ * Whether an advertisement looks like a GAN-protocol cube: a known name prefix, a GAN service UUID,
+ * or GAN-style manufacturer data whose embedded MAC matches the advertiser's address.
+ */
+ fun isGanCube(adv: BleAdvertisement): Boolean =
+ GanDefinitions.NAME_PREFIXES.any { adv.name?.startsWith(it) == true } ||
+ GanGeneration.fromServiceUuids(adv.serviceUuids) != null ||
+ GanDefinitions.macFromManufacturerData(adv.manufacturerData)?.equals(adv.address, ignoreCase = true) == true
+ }
+}
+
+/** A connected GAN cube: decrypts notifications, runs them through the generation's driver and publishes events. */
+class GanSmartCube internal constructor(
+ parent: CoroutineScope,
+ private val conn: BleConnection,
+ val generation: GanGeneration,
+ private val encrypter: GanEncrypter,
+ private val driver: GanProtocolDriver,
+ notifications: Flow<ByteArray>,
+) : SmartCube, GanRawConnection {
+ override val name: String? get() = conn.name
+ override val address: String get() = conn.address
+
+ private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
+ private val _events = MutableSharedFlow<SmartCubeEvent>(extraBufferCapacity = 1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ override val events: SharedFlow<SmartCubeEvent> = _events.asSharedFlow()
+ private val writeLock = Mutex()
+ private var closed = false
+
+ init {
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ notifications.collect { raw ->
+ if (raw.size >= 16) {
+ val plain = encrypter.decrypt(raw)
+ driver.handleStateEvent(this@GanSmartCube, plain).forEach { _events.emit(it) }
+ }
+ }
+ }
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ conn.disconnected.first()
+ finish()
+ }
+ }
+
+ private fun finish() {
+ if (closed) return
+ closed = true
+ _events.tryEmit(SmartCubeEvent.Disconnected)
+ scope.cancel()
+ }
+
+ override suspend fun sendCommandMessage(message: ByteArray) {
+ writeLock.withLock { conn.write(generation.service, generation.command, encrypter.encrypt(message)) }
+ }
+
+ private suspend fun send(command: GanCommand) {
+ driver.createCommandMessage(command)?.let { sendCommandMessage(it) }
+ }
+
+ override suspend fun requestFacelets() = send(GanCommand.REQUEST_FACELETS)
+ override suspend fun requestBattery() = send(GanCommand.REQUEST_BATTERY)
+ override suspend fun requestHardware() = send(GanCommand.REQUEST_HARDWARE)
+ override suspend fun resetToSolved() = send(GanCommand.REQUEST_RESET)
+
+ override suspend fun disconnect() {
+ withContext(NonCancellable) {
+ runCatching { conn.disconnect() }
+ finish()
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt
new file mode 100644
index 0000000..1aef3cd
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt
@@ -0,0 +1,307 @@
+// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License
+// (https://github.com/afedotov/gan-web-bluetooth).
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+
+enum class GanCommand { REQUEST_HARDWARE, REQUEST_FACELETS, REQUEST_BATTERY, REQUEST_RESET }
+
+/** What a protocol driver may do back to the cube while handling an event (move-history requests, disconnect). */
+interface GanRawConnection {
+ /** Encrypts and writes a plaintext command message. */
+ suspend fun sendCommandMessage(message: ByteArray)
+ suspend fun disconnect()
+}
+
+/** Encodes commands and decodes decrypted state notifications for one GAN protocol generation. */
+interface GanProtocolDriver {
+ fun createCommandMessage(command: GanCommand): ByteArray?
+ suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List<SmartCubeEvent>
+}
+
+private const val FACES = "URFDLB"
+private fun moveName(face: Int, direction: Int) = FACES[face].toString() + (if (direction == 1) "'" else "")
+
+private fun msgOf(size: Int, vararg head: Int) = ByteArray(size).also { b -> head.forEachIndexed { i, v -> b[i] = v.toByte() } }
+
+private fun signed15(q: Int): Double = (1 - (q shr 15) * 2) * (q and 0x7FFF) / 0x7FFF.toDouble()
+private fun signed3(v: Int): Int = (1 - (v shr 3) * 2) * (v and 0x7)
+
+private fun readState(r: GanBitReader, cpAt: Int, coAt: Int, epAt: Int, eoAt: Int): CubeState = GanFacelets.complete(
+ cp7 = List(7) { r.bits(cpAt + it * 3, 3) },
+ co7 = List(7) { r.bits(coAt + it * 2, 2) },
+ ep11 = List(11) { r.bits(epAt + it * 4, 4) },
+ eo11 = List(11) { r.bits(eoAt + it, 1) },
+)
+
+private fun facelets(state: CubeState, serial: Int) = SmartCubeEvent.Facelets(GanFacelets.toKociemba(state), serial, state)
+
+private fun gyro(r: GanBitReader, at: Int) = SmartCubeEvent.Gyro(
+ Quaternion(
+ x = signed15(r.bits(at + 16, 16)),
+ y = signed15(r.bits(at + 32, 16)),
+ z = signed15(r.bits(at + 48, 16)),
+ w = signed15(r.bits(at, 16)),
+ ),
+ AngularVelocity(signed3(r.bits(at + 64, 4)), signed3(r.bits(at + 68, 4)), signed3(r.bits(at + 72, 4))),
+)
+
+/**
+ * GAN Gen2: GAN Mini ui FreePlay, GAN12 ui (FreePlay), GAN356 i Carry (S), GAN356 i 3, Monster Go 3Ai, MoYu AI 2023.
+ * 20-byte messages; event type in the first nibble.
+ */
+class GanGen2ProtocolDriver(private val clock: () -> Long) : GanProtocolDriver {
+ private var lastSerial = -1
+ private var lastMoveTimestamp = 0L
+ private var cubeTimestamp = 0L
+
+ override fun createCommandMessage(command: GanCommand): ByteArray = when (command) {
+ GanCommand.REQUEST_FACELETS -> msgOf(20, 0x04)
+ GanCommand.REQUEST_HARDWARE -> msgOf(20, 0x05)
+ GanCommand.REQUEST_BATTERY -> msgOf(20, 0x09)
+ GanCommand.REQUEST_RESET -> msgOf(20, 0x0A, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB)
+ }
+
+ override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List<SmartCubeEvent> {
+ val timestamp = clock()
+ val events = mutableListOf<SmartCubeEvent>()
+ val r = GanBitReader(message)
+ when (r.bits(0, 4)) {
+ 0x01 -> events += gyro(r, 4)
+ 0x02 -> if (lastSerial != -1) { // moves are accepted only after the first facelets event
+ val serial = r.bits(4, 8)
+ val diff = minOf((serial - lastSerial) and 0xFF, 7)
+ lastSerial = serial
+ if (diff > 0) {
+ for (i in diff - 1 downTo 0) {
+ val face = r.bits(12 + 5 * i, 4)
+ val direction = r.bits(16 + 5 * i, 1)
+ var elapsed = r.bits(47 + 16 * i, 16).toLong()
+ if (elapsed == 0L) elapsed = timestamp - lastMoveTimestamp // 16-bit cube timer overflow
+ cubeTimestamp += elapsed
+ if (face < FACES.length) {
+ events += Move(moveName(face, direction), cubeTimestamp, timestamp, (serial - i) and 0xFF, recovered = i != 0)
+ }
+ }
+ lastMoveTimestamp = timestamp
+ }
+ }
+ 0x04 -> {
+ val serial = r.bits(4, 8)
+ if (lastSerial == -1) lastSerial = serial
+ events += facelets(readState(r, 12, 33, 47, 91), serial)
+ }
+ 0x05 -> {
+ val hwMajor = r.bits(8, 8); val hwMinor = r.bits(16, 8)
+ val swMajor = r.bits(24, 8); val swMinor = r.bits(32, 8)
+ val gyroSupported = r.bits(104, 1) == 1
+ val name = (0 until 8).map { r.bits(it * 8 + 40, 8).toChar() }.joinToString("")
+ events += SmartCubeEvent.Hardware(name.trimEnd('\u0000'), "$hwMajor.$hwMinor", "$swMajor.$swMinor", null, gyroSupported)
+ }
+ 0x09 -> events += SmartCubeEvent.Battery(minOf(r.bits(8, 8), 100))
+ 0x0D -> conn.disconnect()
+ }
+ return events
+ }
+}
+
+/**
+ * Shared move FIFO with gap detection and move-history recovery used by Gen3 and Gen4:
+ * live moves carry a serial; when one is skipped, the driver asks the cube for its move history
+ * and holds later moves back until the gap is filled.
+ */
+abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanProtocolDriver {
+ protected var serial = -1
+ protected var lastSerial = -1
+ protected var lastLocalTimestamp: Long? = null
+ private val moveBuffer = ArrayDeque<Move>()
+
+ protected abstract fun moveHistoryMessage(serial: Int, count: Int): ByteArray
+
+ private suspend fun requestMoveHistory(conn: GanRawConnection, serialIn: Int, countIn: Int) {
+ // History data is byte-aligned and always starts at an odd serial: request an odd-aligned, even-sized window,
+ // and never across the 255 -> 0 edge (firmware spoofs those as 'D').
+ var s = serialIn
+ var count = countIn
+ if (s % 2 == 0) s = (s - 1) and 0xFF
+ if (count % 2 == 1) count++
+ count = minOf(count, s + 1)
+ try {
+ conn.sendCommandMessage(moveHistoryMessage(s, count))
+ } catch (e: Exception) {
+ if (e is kotlinx.coroutines.CancellationException) throw e
+ // Ignored: retried on the next move event.
+ }
+ }
+
+ protected suspend fun evictMoveBuffer(conn: GanRawConnection?): List<SmartCubeEvent> {
+ val evicted = mutableListOf<SmartCubeEvent>()
+ while (moveBuffer.isNotEmpty()) {
+ val head = moveBuffer.first()
+ val diff = if (lastSerial == -1) 1 else (head.serial - lastSerial) and 0xFF
+ if (diff > 1) {
+ if (conn != null) requestMoveHistory(conn, head.serial, diff)
+ break
+ } else {
+ evicted += moveBuffer.removeFirst()
+ lastSerial = head.serial
+ }
+ }
+ // Something went wrong and the buffer no longer drains: drop the connection.
+ if (conn != null && moveBuffer.size > 16) conn.disconnect()
+ return evicted
+ }
+
+ private fun isSerialInRange(start: Int, end: Int, serial: Int, closedStart: Boolean = false, closedEnd: Boolean = false) =
+ ((end - start) and 0xFF) >= ((serial - start) and 0xFF) &&
+ (closedStart || ((start - serial) and 0xFF) > 0) &&
+ (closedEnd || ((end - serial) and 0xFF) > 0)
+
+ private fun injectMissedMove(move: Move) {
+ if (moveBuffer.isNotEmpty()) {
+ val head = moveBuffer.first()
+ if (moveBuffer.any { it.serial == move.serial }) return
+ if (!isSerialInRange(lastSerial, head.serial, move.serial)) return
+ // History is injected in reverse order, so only the move right before the head fits.
+ if (move.serial == ((head.serial - 1) and 0xFF)) moveBuffer.addFirst(move)
+ } else if (isSerialInRange(lastSerial, serial, move.serial, closedEnd = true)) {
+ // A lost move recovered via the periodic facelets event, into an empty buffer.
+ moveBuffer.addFirst(move)
+ }
+ }
+
+ private suspend fun checkIfMoveMissed(conn: GanRawConnection) {
+ val diff = (serial - lastSerial) and 0xFF
+ if (diff > 0 && serial != 0) { // serial 0: firmware bug with facelets event at counter 255
+ val startSerial = moveBuffer.firstOrNull()?.serial ?: ((serial + 1) and 0xFF)
+ requestMoveHistory(conn, startSerial, diff + 1)
+ }
+ }
+
+ protected suspend fun onLiveMove(conn: GanRawConnection, timestamp: Long, cubeTimestamp: Long, moveSerial: Int, direction: Int, faceBits: Int): List<SmartCubeEvent> {
+ if (lastSerial == -1) return emptyList() // moves are accepted only after the first facelets event
+ lastLocalTimestamp = timestamp
+ serial = moveSerial
+ val face = LIVE_FACE_BITS.indexOf(faceBits)
+ if (face >= 0) moveBuffer.addLast(Move(moveName(face, direction), cubeTimestamp, timestamp, moveSerial))
+ return evictMoveBuffer(conn)
+ }
+
+ protected suspend fun onMoveHistory(r: GanBitReader, timestamp: Long, startSerial: Int, dataLength: Int, movesAt: Int): List<SmartCubeEvent> {
+ val count = (dataLength - 1) * 2
+ for (i in 0 until count) {
+ val face = HISTORY_FACE_BITS.indexOf(r.bits(movesAt + 4 * i, 3))
+ val direction = r.bits(movesAt + 3 + 4 * i, 1)
+ if (face >= 0) injectMissedMove(Move(moveName(face, direction), null, timestamp, (startSerial - i) and 0xFF, recovered = true))
+ }
+ return evictMoveBuffer(null)
+ }
+
+ protected suspend fun onFaceletsSerial(conn: GanRawConnection, timestamp: Long, faceletsSerial: Int) {
+ serial = faceletsSerial
+ // The cube sends facelets periodically; use them to detect missed moves, debounced while turning.
+ if (lastSerial != -1) {
+ val last = lastLocalTimestamp
+ if (last != null && timestamp - last > 500) checkIfMoveMissed(conn)
+ }
+ if (lastSerial == -1) lastSerial = faceletsSerial
+ }
+
+ private companion object {
+ val LIVE_FACE_BITS = listOf(2, 32, 8, 1, 16, 4)
+ val HISTORY_FACE_BITS = listOf(1, 5, 3, 0, 4, 2)
+ }
+}
+
+/** GAN Gen3: GAN356 i Carry 2. 16-byte messages starting with magic 0x55, type, length. */
+class GanGen3ProtocolDriver(clock: () -> Long) : GanBufferedProtocolDriver(clock) {
+ override fun createCommandMessage(command: GanCommand): ByteArray = when (command) {
+ GanCommand.REQUEST_FACELETS -> msgOf(16, 0x68, 0x01)
+ GanCommand.REQUEST_HARDWARE -> msgOf(16, 0x68, 0x04)
+ GanCommand.REQUEST_BATTERY -> msgOf(16, 0x68, 0x07)
+ GanCommand.REQUEST_RESET -> msgOf(16, 0x68, 0x05, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB)
+ }
+
+ override fun moveHistoryMessage(serial: Int, count: Int) = msgOf(16, 0x68, 0x03, serial, 0, count, 0)
+
+ override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List<SmartCubeEvent> {
+ val timestamp = clock()
+ val r = GanBitReader(message)
+ val magic = r.bits(0, 8)
+ val eventType = r.bits(8, 8)
+ val dataLength = r.bits(16, 8)
+ if (magic != 0x55 || dataLength <= 0) return emptyList()
+ return when (eventType) {
+ 0x01 -> onLiveMove(conn, timestamp, r.u32(24, true), r.bits(56, 16, true) and 0xFF, r.bits(72, 2), r.bits(74, 6))
+ 0x06 -> onMoveHistory(r, timestamp, r.bits(24, 8), dataLength, 32)
+ 0x02 -> {
+ val s = r.bits(24, 16, true) and 0xFF
+ onFaceletsSerial(conn, timestamp, s)
+ listOf(facelets(readState(r, 40, 61, 77, 121), s))
+ }
+ 0x07 -> {
+ val swMajor = r.bits(72, 4); val swMinor = r.bits(76, 4)
+ val hwMajor = r.bits(80, 4); val hwMinor = r.bits(84, 4)
+ val name = (0 until 5).map { r.bits(it * 8 + 32, 8).toChar() }.joinToString("")
+ listOf(SmartCubeEvent.Hardware(name.trimEnd('\u0000'), "$hwMajor.$hwMinor", "$swMajor.$swMinor", null, false))
+ }
+ 0x10 -> listOf(SmartCubeEvent.Battery(minOf(r.bits(24, 8), 100)))
+ 0x11 -> { conn.disconnect(); emptyList() }
+ else -> emptyList()
+ }
+ }
+}
+
+/** GAN Gen4: GAN12 ui Maglev, GAN14 ui FreePlay. 20-byte messages starting with type, length. */
+class GanGen4ProtocolDriver(clock: () -> Long) : GanBufferedProtocolDriver(clock) {
+ private val hwInfo = mutableMapOf<Int, String>()
+
+ override fun createCommandMessage(command: GanCommand): ByteArray = when (command) {
+ GanCommand.REQUEST_FACELETS -> msgOf(20, 0xDD, 0x04, 0x00, 0xED, 0x00, 0x00)
+ GanCommand.REQUEST_HARDWARE -> { hwInfo.clear(); msgOf(20, 0xDF, 0x03, 0x00, 0x00, 0x00) }
+ GanCommand.REQUEST_BATTERY -> msgOf(20, 0xDD, 0x04, 0x00, 0xEF, 0x00, 0x00)
+ GanCommand.REQUEST_RESET -> msgOf(20, 0xD2, 0x0D, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB)
+ }
+
+ override fun moveHistoryMessage(serial: Int, count: Int) = msgOf(20, 0xD1, 0x04, serial, 0, count, 0)
+
+ override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List<SmartCubeEvent> {
+ val timestamp = clock()
+ val r = GanBitReader(message)
+ val eventType = r.bits(0, 8)
+ val dataLength = r.bits(8, 8)
+ return when (eventType) {
+ 0x01 -> onLiveMove(conn, timestamp, r.u32(16, true), r.bits(48, 16, true) and 0xFF, r.bits(64, 2), r.bits(66, 6))
+ 0xD1 -> onMoveHistory(r, timestamp, r.bits(16, 8), dataLength, 24)
+ 0xED -> {
+ val s = r.bits(16, 16, true) and 0xFF
+ onFaceletsSerial(conn, timestamp, s)
+ listOf(facelets(readState(r, 32, 53, 69, 113), s))
+ }
+ in 0xFA..0xFE -> {
+ when (eventType) {
+ 0xFA -> {
+ val year = r.bits(24, 16, true); val month = r.bits(40, 8); val day = r.bits(48, 8)
+ hwInfo[eventType] = "${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}"
+ }
+ 0xFC -> hwInfo[eventType] = (0 until maxOf(0, minOf(dataLength - 1, (message.size * 8 - 24) / 8)))
+ .map { r.bits(it * 8 + 24, 8).toChar() }.joinToString("")
+ 0xFD -> hwInfo[eventType] = "${r.bits(24, 4)}.${r.bits(28, 4)}"
+ 0xFE -> hwInfo[eventType] = "${r.bits(24, 4)}.${r.bits(28, 4)}"
+ }
+ if (hwInfo.size == 4) {
+ val name = hwInfo[0xFC]
+ listOf(SmartCubeEvent.Hardware(name, hwInfo[0xFE], hwInfo[0xFD], hwInfo[0xFA], name in GYRO_MODELS))
+ } else emptyList()
+ }
+ 0xEC -> listOf(gyro(r, 16))
+ 0xEF -> listOf(SmartCubeEvent.Battery(minOf(r.bits(8 + dataLength * 8, 8), 100)))
+ 0xEA -> { conn.disconnect(); emptyList() }
+ else -> emptyList()
+ }
+ }
+
+ private companion object {
+ val GYRO_MODELS = setOf("GAN12uiM")
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt
new file mode 100644
index 0000000..c5521f0
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt
@@ -0,0 +1,138 @@
+// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License
+// (https://github.com/afedotov/gan-web-bluetooth).
+package coffee.liz.rookery.bt
+
+import kotlin.math.roundToLong
+
+/**
+ * Reads arbitrary bit words from a message, MSB-first; 16/32-bit words may be little-endian.
+ * Bits past the end read as 0.
+ */
+class GanBitReader(private val msg: ByteArray) {
+ private fun bit(i: Int): Int = if (i ushr 3 >= msg.size) 0 else (msg[i ushr 3].toInt() ushr (7 - (i and 7))) and 1
+
+ fun bits(start: Int, length: Int, littleEndian: Boolean = false): Int = when {
+ length <= 8 -> {
+ var v = 0
+ for (i in start until start + length) v = (v shl 1) or bit(i)
+ v
+ }
+ length == 16 || length == 32 -> {
+ val n = length / 8
+ val bytes = IntArray(n) { bits(start + 8 * it, 8) }
+ var v = 0L
+ for (i in 0 until n) v = (v shl 8) or (if (littleEndian) bytes[n - 1 - i] else bytes[i]).toLong()
+ v.toInt()
+ }
+ else -> throw IllegalArgumentException("Unsupported bit word length $length")
+ }
+
+ /** Unsigned 32-bit word as Long. */
+ fun u32(start: Int, littleEndian: Boolean = false): Long = bits(start, 32, littleEndian).toLong() and 0xFFFFFFFFL
+}
+
+/** Writes bit words MSB-first; the inverse of [GanBitReader] (used by tests and encoders). */
+class GanBitWriter(size: Int) {
+ val bytes = ByteArray(size)
+
+ fun put(start: Int, length: Int, value: Long, littleEndian: Boolean = false): GanBitWriter {
+ if (length == 16 || length == 32) {
+ val n = length / 8
+ for (i in 0 until n) {
+ val shift = if (littleEndian) 8 * i else 8 * (n - 1 - i)
+ put(start + 8 * i, 8, (value ushr shift) and 0xFF)
+ }
+ return this
+ }
+ require(length <= 8)
+ for (i in 0 until length) {
+ val b = ((value ushr (length - 1 - i)) and 1L).toInt()
+ val pos = start + i
+ val idx = pos ushr 3
+ val mask = 1 shl (7 - (pos and 7))
+ bytes[idx] = (if (b == 1) bytes[idx].toInt() or mask else bytes[idx].toInt() and mask.inv()).toByte()
+ }
+ return this
+ }
+
+ fun put(start: Int, length: Int, value: Int, littleEndian: Boolean = false) = put(start, length, value.toLong() and 0xFFFFFFFFL, littleEndian)
+}
+
+object GanFacelets {
+ private val CORNER_FACELET_MAP = arrayOf(
+ intArrayOf(8, 9, 20), intArrayOf(6, 18, 38), intArrayOf(0, 36, 47), intArrayOf(2, 45, 11),
+ intArrayOf(29, 26, 15), intArrayOf(27, 44, 24), intArrayOf(33, 53, 42), intArrayOf(35, 17, 51),
+ )
+ private val EDGE_FACELET_MAP = arrayOf(
+ intArrayOf(5, 10), intArrayOf(7, 19), intArrayOf(3, 37), intArrayOf(1, 46),
+ intArrayOf(32, 16), intArrayOf(28, 25), intArrayOf(30, 43), intArrayOf(34, 52),
+ intArrayOf(23, 12), intArrayOf(21, 41), intArrayOf(50, 39), intArrayOf(48, 14),
+ )
+ private const val FACES = "URFDLB"
+
+ const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
+
+ /** Corner/edge permutation+orientation to a Kociemba facelets string. */
+ fun toKociemba(s: CubeState): String {
+ val f = CharArray(54) { FACES[it / 9] }
+ for (i in 0 until 8) for (p in 0 until 3)
+ f[CORNER_FACELET_MAP[i][(p + s.co[i]) % 3]] = FACES[CORNER_FACELET_MAP[s.cp[i]][p] / 9]
+ for (i in 0 until 12) for (p in 0 until 2)
+ f[EDGE_FACELET_MAP[i][(p + s.eo[i]) % 2]] = FACES[EDGE_FACELET_MAP[s.ep[i]][p] / 9]
+ return f.concatToString()
+ }
+
+ /** Fill in the 8th corner / 12th edge from the first 7/11 (the cube only sends those). */
+ fun complete(cp7: List<Int>, co7: List<Int>, ep11: List<Int>, eo11: List<Int>): CubeState = CubeState(
+ cp = cp7 + (28 - cp7.sum()),
+ co = co7 + ((3 - co7.sum() % 3) % 3),
+ ep = ep11 + (66 - ep11.sum()),
+ eo = eo11 + ((2 - eo11.sum() % 2) % 2),
+ )
+}
+
+/** A move with optional timestamps, for [GanTimestampFit]. */
+data class TimedMove(val move: String, val hostTimestampMs: Long?, val cubeTimestampMs: Long?)
+
+object GanTimestampFit {
+ private fun linregress(x: List<Long?>, y: List<Long?>): Pair<Double, Double> {
+ var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumXX = 0.0; var n = 0
+ for (i in x.indices) {
+ val xi = x[i]?.toDouble() ?: continue
+ val yi = y[i]?.toDouble() ?: continue
+ n++; sumX += xi; sumY += yi; sumXY += xi * yi; sumXX += xi * xi
+ }
+ val varX = n * sumXX - sumX * sumX
+ val covXY = n * sumXY - sumX * sumY
+ val slope = if (varX < 1e-3) 1.0 else covXY / varX
+ val intercept = if (n < 1) 0.0 else sumY / n - slope * sumX / n
+ return slope to intercept
+ }
+
+ /**
+ * Fit cube-clock timestamps to host timestamps by linear regression, first filling missing cube
+ * timestamps from neighbours (next -50 ms, then previous +50 ms). Returned cube timestamps are in
+ * host-clock milliseconds relative to the first move (so the first is 0).
+ */
+ fun linearFit(moves: List<TimedMove>): List<TimedMove> {
+ if (moves.isEmpty()) return emptyList()
+ val cube = moves.map { it.cubeTimestampMs }.toMutableList()
+ if (cube.size >= 2) {
+ for (i in cube.size - 1 downTo 1) if (cube[i] != null && cube[i - 1] == null) cube[i - 1] = cube[i]!! - 50
+ for (i in 0 until cube.size - 1) if (cube[i] != null && cube[i + 1] == null) cube[i + 1] = cube[i]!! + 50
+ }
+ val (slope, intercept) = linregress(cube, moves.map { it.hostTimestampMs })
+ val first = cube[0]?.let { (slope * it + intercept).roundToLong() }
+ return moves.mapIndexed { i, m ->
+ val c = cube[i]
+ m.copy(cubeTimestampMs = if (c == null || first == null) null else (slope * c + intercept).roundToLong() - first)
+ }
+ }
+
+ /** Skew of the cube clock vs the host clock, in percent. */
+ fun skewPercent(moves: List<TimedMove>): Double {
+ if (moves.isEmpty()) return 0.0
+ val (slope, _) = linregress(moves.map { it.hostTimestampMs }, moves.map { it.cubeTimestampMs })
+ return ((slope - 1) * 100000).roundToLong() / 1000.0
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
new file mode 100644
index 0000000..747a391
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt
@@ -0,0 +1,58 @@
+package coffee.liz.rookery.bt
+
+import kotlinx.coroutines.flow.Flow
+
+/** Orientation quaternion; right-handed, +X red, +Y blue, +Z white (GAN convention). */
+data class Quaternion(val x: Double, val y: Double, val z: Double, val w: Double)
+
+/** Coarse angular velocity per axis as reported by the cube (small signed integers). */
+data class AngularVelocity(val x: Int, val y: Int, val z: Int)
+
+/** Corner/edge permutation and orientation as reported by the cube. */
+data class CubeState(val cp: List<Int>, val co: List<Int>, val ep: List<Int>, val eo: List<Int>)
+
+sealed interface SmartCubeEvent {
+ /**
+ * A face turn in WCA notation ("R", "U'").
+ * [cubeTimestampMs] is the cube's own clock (null if unknown, e.g. a move recovered from history);
+ * [hostTimestampMs] is the host's monotonic clock when the notification arrived.
+ * [recovered] marks moves that were missed live and filled in from the cube's move history,
+ * whose host timestamp is therefore not meaningful.
+ */
+ data class Move(
+ val move: String,
+ val cubeTimestampMs: Long?,
+ val hostTimestampMs: Long,
+ val serial: Int = -1,
+ val recovered: Boolean = false,
+ ) : SmartCubeEvent
+
+ /** Kociemba-style 54-char facelets in U R F D L B order. */
+ data class Facelets(val facelets: String, val serial: Int = -1, val state: CubeState? = null) : SmartCubeEvent
+
+ data class Battery(val percent: Int) : SmartCubeEvent
+
+ data class Hardware(
+ val name: String?,
+ val hardwareVersion: String?,
+ val softwareVersion: String?,
+ val productDate: String? = null,
+ val gyroSupported: Boolean = false,
+ ) : SmartCubeEvent
+
+ data class Gyro(val quaternion: Quaternion, val velocity: AngularVelocity? = null) : SmartCubeEvent
+
+ data object Disconnected : SmartCubeEvent
+}
+
+/** A connected smart cube. [events] is hot: collect it before issuing requests to see their replies. */
+interface SmartCube {
+ val name: String?
+ val address: String
+ val events: Flow<SmartCubeEvent>
+ suspend fun requestFacelets()
+ suspend fun requestBattery()
+ suspend fun requestHardware()
+ suspend fun resetToSolved()
+ suspend fun disconnect()
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt
new file mode 100644
index 0000000..5d398bd
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt
@@ -0,0 +1,104 @@
+package coffee.liz.rookery.importer
+
+import coffee.liz.rookery.model.CubeDevice
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Session
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.util.fnv64
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+
+/**
+ * Parses a csTimer export (the `.txt` from Export → to file).
+ *
+ * Shape: `{"session1": [solve...], ..., "properties": {"sessionData": "<json string>", "giiMacMap": "<json string>"}}`
+ * where a solve is `[[penalty, ms], scramble, comment, unixSeconds, [moves, puzzle]?]` and penalty is
+ * 0, 2000 (+2) or -1 (DNF).
+ *
+ * csTimer lets one session hold several puzzles, so each (session, event) pair becomes its own session
+ * here. Ids are hashes of the source data, so importing the same file twice changes nothing.
+ */
+object CsTimerImport {
+ data class Result(val sessions: List<Session>, val solves: List<Solve>, val devices: List<CubeDevice>, val warnings: List<String>)
+
+ private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+
+ fun parse(text: String): Result {
+ val root = json.parseToJsonElement(text).jsonObject
+ val props = root["properties"] as? JsonObject
+ val sessionData = props?.get("sessionData")?.let(::embedded)
+ val warnings = mutableListOf<String>()
+
+ val sessions = LinkedHashMap<String, Session>()
+ val solves = mutableListOf<Solve>()
+
+ val keys = root.keys.filter { it.startsWith("session") }.sortedBy { it.removePrefix("session").toIntOrNull() ?: 0 }
+ for (key in keys) {
+ val num = key.removePrefix("session")
+ val meta = sessionData?.get(num) as? JsonObject
+ val name = (meta?.get("name") as? JsonPrimitive)?.contentOrNull ?: num
+ val scrType = ((meta?.get("opt") as? JsonObject)?.get("scrType") as? JsonPrimitive)?.contentOrNull
+ val sessionEvent = EventGuess.fromCsTimerType(scrType)
+ if (scrType != null && sessionEvent == null) warnings += "session $name: unknown scramble type '$scrType', guessing per solve"
+
+ for ((i, el) in (root[key] as? JsonArray ?: continue).withIndex()) {
+ val arr = el as? JsonArray ?: continue
+ try {
+ val timeArr = arr[0].jsonArray
+ val pen = timeArr[0].jsonPrimitive.longOrNull ?: 0
+ val ms = timeArr[1].jsonPrimitive.longOrNull ?: continue
+ val scramble = arr.getOrNull(1)?.jsonPrimitive?.contentOrNull.orEmpty()
+ val comment = arr.getOrNull(2)?.jsonPrimitive?.contentOrNull.orEmpty()
+ val at = (arr.getOrNull(3)?.jsonPrimitive?.longOrNull ?: 0) * 1000
+ val moves = (arr.getOrNull(4) as? JsonArray)?.getOrNull(0)?.jsonPrimitive?.contentOrNull
+
+ // The scramble wins when it clearly names a different puzzle than the session's type.
+ val sniffed = EventGuess.fromScramble(scramble)
+ val event = when {
+ sniffed != null && sniffed.puzzle != sessionEvent?.puzzle && (sessionEvent == null || EventGuess.isUnambiguous(sniffed)) -> sniffed
+ sessionEvent != null -> sessionEvent
+ else -> sniffed ?: Event.E333
+ }
+ val sid = "cst-" + fnv64(num, event.id)
+ sessions.getOrPut(sid) {
+ val suffix = if (sessionEvent == null || event != sessionEvent) " (${event.short})" else ""
+ Session(sid, "csTimer $name$suffix", event, at)
+ }.let { if (at < it.createdAt) sessions[sid] = it.copy(createdAt = at) }
+
+ solves += Solve(
+ id = "cst-" + fnv64(at.toString(), ms.toString(), scramble),
+ sessionId = sid, event = event, ms = ms,
+ penalty = when { pen < 0 -> Penalty.DNF; pen > 0 -> Penalty.PLUS2; else -> Penalty.NONE },
+ scramble = scramble, comment = comment, at = at, moves = moves,
+ )
+ } catch (e: Exception) {
+ warnings += "$key #${i + 1}: skipped (${e.message})"
+ }
+ }
+ }
+
+ val devices = props?.get("giiMacMap")?.let(::embedded)?.values
+ ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
+ ?.filter { it.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) }
+ ?.distinct()
+ ?.map { CubeDevice(it.uppercase(), "Smart cube (from csTimer)", "gan") }
+ .orEmpty()
+
+ return Result(sessions.values.toList(), solves, devices, warnings)
+ }
+
+ /** csTimer stores some properties as JSON-encoded strings inside the JSON. */
+ private fun embedded(e: kotlinx.serialization.json.JsonElement): JsonObject? = when (e) {
+ is JsonObject -> e
+ is JsonPrimitive -> e.contentOrNull?.let { runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() }
+ else -> null
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt
new file mode 100644
index 0000000..c8c5624
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt
@@ -0,0 +1,58 @@
+package coffee.liz.rookery.importer
+
+import coffee.liz.rookery.model.Event
+
+/** Best-effort mapping from csTimer scramble types and raw scrambles to events. */
+object EventGuess {
+ /** csTimer `scrType` values → events. Unknown types fall back to scramble sniffing. */
+ fun fromCsTimerType(t: String?): Event? = when (t) {
+ null, "" -> null
+ "333", "333o", "333oh", "333ft" -> if (t == "333oh") Event.E333OH else Event.E333
+ "222so", "222o", "2223", "222eg" -> Event.E222
+ "444wca", "444m", "444o", "444" -> Event.E444
+ "555wca", "555", "555bld" -> if (t == "555bld") Event.E555BF else Event.E555
+ "666wca", "666si", "666" -> Event.E666
+ "777wca", "777si", "777" -> Event.E777
+ "333ni", "333bld" -> Event.E333BF
+ "444bld" -> Event.E444BF
+ "r3ni" -> Event.E333MBF
+ "333fm" -> Event.E333FM
+ "clkwca", "clk", "clko" -> Event.CLOCK
+ "mgmp", "mgmc", "mgmo" -> Event.MINX
+ "pyrso", "pyrm", "pyro" -> Event.PYRAM
+ "skbso", "skbo" -> Event.SKEWB
+ "sqrs", "sq1h", "sq1t" -> Event.SQ1
+ "ftoso", "fto" -> Event.FTO
+ else -> null
+ }
+
+ /**
+ * Whether a scramble-text guess is distinctive enough to override what the session says.
+ * Short 2x2 / Pyraminx / Skewb scrambles look alike, so those never override.
+ */
+ fun isUnambiguous(e: Event): Boolean = e.puzzle !in setOf(
+ coffee.liz.rookery.model.Puzzle.CUBE2, coffee.liz.rookery.model.Puzzle.PYRAMINX, coffee.liz.rookery.model.Puzzle.SKEWB,
+ )
+
+ /** Guess from the scramble text alone. Returns null when it looks like a plain 3x3 scramble. */
+ fun fromScramble(scr: String): Event? {
+ val s = scr.trim()
+ if (s.isEmpty()) return null
+ if (s.contains('/') && s.contains('(')) return Event.SQ1
+ if (Regex("""\b(UR|DR|DL|UL|ALL)\d[+-]""").containsMatchIn(s) || s.contains("y2 ")) return Event.CLOCK
+ if (s.contains("++") || s.contains("--")) return Event.MINX
+ val tokens = s.split(Regex("\\s+"))
+ if (tokens.any { it.matches(Regex("""(BR|BL)'?""")) }) return Event.FTO
+ if (tokens.all { it.matches(Regex("""[ULRBulrb]'?""")) }) {
+ return if (tokens.any { it[0].isLowerCase() }) Event.PYRAM else Event.SKEWB
+ }
+ val wide3 = tokens.any { it.matches(Regex("""3[URFDLB]w.*""")) }
+ val wide = tokens.any { it.contains('w') }
+ return when {
+ wide3 -> if (tokens.size >= 90) Event.E777 else Event.E666
+ wide -> if (tokens.size >= 55) Event.E555 else Event.E444
+ tokens.size in 8..12 && tokens.all { it.matches(Regex("""[URF][2']?""")) } -> Event.E222
+ else -> null
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt
new file mode 100644
index 0000000..25b39a6
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt
@@ -0,0 +1,81 @@
+package coffee.liz.rookery.importer
+
+import coffee.liz.rookery.store.Coll
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.util.fnv64
+import kotlinx.serialization.json.JsonPrimitive
+
+/** Writes parsed imports into the store. Every id is derived from source data, so re-running is a no-op. */
+class Importer(private val repo: Repo) {
+ data class Summary(val lines: List<String>, val warnings: List<String> = emptyList())
+
+ fun csTimer(text: String): Summary {
+ val r = CsTimerImport.parse(text)
+ val existingSessions = repo.sessions().map { it.id }.toSet()
+ val existingSolves = repo.solves().map { it.id }.toSet()
+ val existingDevices = repo.devices().map { it.mac }.toSet()
+ val newSessions = r.sessions.filter { it.id !in existingSessions }
+ val newSolves = r.solves.filter { it.id !in existingSolves }
+ val newDevices = r.devices.filter { it.mac !in existingDevices }
+ repo.store.transact {
+ for (s in newSessions) {
+ set(Coll.SESSION, s.id, "name", s.name); set(Coll.SESSION, s.id, "event", s.event.id); set(Coll.SESSION, s.id, "created", s.createdAt)
+ }
+ with(repo) { for (s in newSolves) writeSolve(s) }
+ for (d in newDevices) {
+ set(Coll.DEVICE, d.mac, "name", d.name); set(Coll.DEVICE, d.mac, "proto", d.protocol)
+ }
+ }
+ val byEvent = newSolves.groupingBy { it.event.short }.eachCount().entries.joinToString { "${it.value} ${it.key}" }
+ return Summary(
+ listOfNotNull(
+ "${newSolves.size} new solves" + (if (byEvent.isNotEmpty()) " ($byEvent)" else "") + ", ${r.solves.size - newSolves.size} already here",
+ "${newSessions.size} new sessions",
+ "${newSolves.count { it.moves != null }} with smart-cube reconstructions",
+ if (newDevices.isNotEmpty()) "remembered smart cube ${newDevices.joinToString { it.mac }}" else null,
+ ),
+ r.warnings,
+ )
+ }
+
+ /** [image] loads a file from the export by its relative path (null if missing). */
+ fun letterPairGym(text: String, image: (String) -> ByteArray?): Summary {
+ val r = LetterPairGymImport.parse(text)
+ val existing = repo.letterPairs()
+ val warnings = mutableListOf<String>()
+ var images = 0
+ val blobByPair = r.images.mapNotNull { (pair, path) ->
+ val bytes = image(path)
+ if (bytes == null) { warnings += "$pair: image $path missing from export"; null }
+ else pair to repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg").lowercase())
+ }.toMap()
+
+ repo.store.transact {
+ for ((pair, word) in r.words) {
+ val cur = existing[pair]
+ if (cur?.primary != word) set(Coll.PAIR, pair, "primary", word)
+ if (cur == null || word !in cur.words) set(Coll.PAIR, pair, "w:$word", true)
+ }
+ for ((pair, note) in r.notes) if (existing[pair]?.note != note) set(Coll.PAIR, pair, "note", note)
+ for ((pair, blob) in blobByPair) if (existing[pair]?.image != blob) { set(Coll.PAIR, pair, "image", blob); images++ }
+ for (h in r.history) {
+ set(Coll.PAIR_HISTORY, fnv64(h.pair, h.from.orEmpty(), h.to.orEmpty(), h.at.toString()), "v", Repo.historyJson(h))
+ }
+ for ((pair, s) in r.stats) set(Coll.PAIR, pair, "gymStats", s.raw)
+ for (m in r.memo) {
+ val id = "lpg-" + m.at
+ set(Coll.MEMO, id, "at", m.at); set(Coll.MEMO, id, "pairs", m.pairs); set(Coll.MEMO, id, "ms", m.memoMs); set(Coll.MEMO, id, "ok", m.recalled)
+ }
+ r.settings?.let { set(Coll.PREFS, "global", "importedLetterPairGymSettings", JsonPrimitive(it.toString())) }
+ }
+ return Summary(
+ listOf(
+ "${r.words.size} letter pairs (${r.words.keys.count { it !in existing }} new)",
+ "${r.notes.size} notes, $images images",
+ "${r.history.size} word changes, practice stats for ${r.stats.size} pairs",
+ "${r.memo.size} memo sessions",
+ ),
+ warnings,
+ )
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt
new file mode 100644
index 0000000..b6a84a1
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt
@@ -0,0 +1,78 @@
+package coffee.liz.rookery.importer
+
+import coffee.liz.rookery.model.MemoSession
+import coffee.liz.rookery.model.WordChange
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.longOrNull
+
+/**
+ * Reads the Letter Pair Gym prototype's `backup.json` (app = "letter-pair-gym", v = 1).
+ *
+ * Known stats fields per pair: n = reps, m = blanks, s = slow, l = recall latencies (ms),
+ * r = recent results oldest→newest (g good, s slow, m miss), ms = total time. `k`, `t`, `x` are
+ * kept verbatim until we know what they mean.
+ */
+object LetterPairGymImport {
+ data class PairStats(val reps: Int, val blanks: Int, val slow: Int, val latenciesMs: List<Long>, val recent: String, val raw: JsonObject)
+
+ data class Result(
+ val words: Map<String, String>,
+ val notes: Map<String, String>,
+ /** pair → path inside the export, e.g. `images/AB - abacus.jpg` */
+ val images: Map<String, String>,
+ val history: List<WordChange>,
+ val stats: Map<String, PairStats>,
+ val memo: List<MemoSession>,
+ /** Raw settings, for the scheme-confirmation step. */
+ val settings: JsonObject?,
+ val exportedAt: String?,
+ )
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun parse(text: String): Result {
+ val root = json.parseToJsonElement(text).jsonObject
+ val app = root.str("app")
+ require(app == "letter-pair-gym") { "not a Letter Pair Gym backup (app = $app)" }
+
+ fun strMap(key: String): Map<String, String> = (root[key] as? JsonObject).orEmpty()
+ .mapNotNull { (k, v) -> (v as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { k.uppercase() to it.trim() } }
+ .toMap()
+
+ val history = (root["wordHist"] as? JsonArray).orEmpty().mapNotNull { e ->
+ val o = e as? JsonObject ?: return@mapNotNull null
+ WordChange(o.str("p")?.uppercase() ?: return@mapNotNull null, o.str("from"), o.str("to"), o.long("d") ?: 0, "letter-pair-gym")
+ }
+
+ val stats = (root["stats"] as? JsonObject).orEmpty().mapNotNull { (pair, e) ->
+ val o = e as? JsonObject ?: return@mapNotNull null
+ pair.uppercase() to PairStats(
+ reps = o.long("n")?.toInt() ?: 0,
+ blanks = o.long("m")?.toInt() ?: 0,
+ slow = o.long("s")?.toInt() ?: 0,
+ latenciesMs = (o["l"] as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.doubleOrNull?.toLong() },
+ recent = o.str("r").orEmpty(),
+ raw = o,
+ )
+ }.toMap()
+
+ val memo = (root["memoHist"] as? JsonArray).orEmpty().mapNotNull { e ->
+ val o = e as? JsonObject ?: return@mapNotNull null
+ MemoSession(o.long("d") ?: return@mapNotNull null, o.long("p")?.toInt() ?: 0, o.long("ms") ?: 0, o.long("ok")?.toInt() ?: 0)
+ }
+
+ return Result(strMap("words"), strMap("notes"), strMap("images"), history, stats, memo, root["settings"] as? JsonObject, root.str("at"))
+ }
+
+ private fun JsonObject.str(k: String): String? = (this[k] as? JsonPrimitive)?.contentOrNull
+ private fun JsonObject.long(k: String): Long? = (this[k] as? JsonPrimitive)?.let { it.longOrNull ?: it.doubleOrNull?.toLong() }
+}
+
+internal fun JsonElement?.orEmptyObject(): JsonObject = this as? JsonObject ?: JsonObject(emptyMap())
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt
new file mode 100644
index 0000000..5090ee0
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt
@@ -0,0 +1,50 @@
+package coffee.liz.rookery.model
+
+enum class Penalty(val code: String) {
+ NONE("ok"), PLUS2("+2"), DNF("dnf");
+ companion object { fun of(code: String?) = entries.firstOrNull { it.code == code } ?: NONE }
+}
+
+/** Result in ms after penalties; [DNF] sorts after every real time. */
+const val DNF: Long = Long.MAX_VALUE
+
+data class Session(val id: String, val name: String, val event: Event, val createdAt: Long)
+
+data class Solve(
+ val id: String,
+ val sessionId: String,
+ val event: Event,
+ /** Raw timer reading, before penalties. */
+ val ms: Long,
+ val penalty: Penalty,
+ val scramble: String,
+ val comment: String,
+ /** Epoch ms when the solve finished. */
+ val at: Long,
+ /** Smart-cube reconstruction, csTimer style: `R@0 U'@120 ...` */
+ val moves: String? = null,
+) {
+ val result: Long get() = when (penalty) {
+ Penalty.NONE -> ms
+ Penalty.PLUS2 -> ms + 2000
+ Penalty.DNF -> DNF
+ }
+}
+
+data class LetterPair(
+ val pair: String,
+ val primary: String?,
+ val words: Set<String>,
+ val note: String?,
+ /** Blob name of the chosen image. */
+ val image: String?,
+ /** 0 (easy) .. 1 (always missed) from practice history, null if never drilled. */
+ val difficulty: Double? = null,
+)
+
+data class WordChange(val pair: String, val from: String?, val to: String?, val at: Long, val source: String)
+
+data class MemoSession(val at: Long, val pairs: Int, val memoMs: Long, val recalled: Int)
+
+/** A smart cube we have paired with on some device. Synced so every device knows its MAC. */
+data class CubeDevice(val mac: String, val name: String, val protocol: String)
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt
new file mode 100644
index 0000000..db7fcb8
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt
@@ -0,0 +1,37 @@
+package coffee.liz.rookery.model
+
+/** Puzzles the engine knows how to scramble (and, over time, render and simulate). */
+enum class Puzzle { CUBE2, CUBE3, CUBE4, CUBE5, CUBE6, CUBE7, PYRAMINX, SKEWB, MEGAMINX, SQUARE1, CLOCK, FTO;
+ /** Layers per side for NxN puzzles, otherwise null. */
+ val cubeSize: Int? get() = when (this) {
+ CUBE2 -> 2; CUBE3 -> 3; CUBE4 -> 4; CUBE5 -> 5; CUBE6 -> 6; CUBE7 -> 7; else -> null
+ }
+}
+
+/** Events, keyed by WCA event id where one exists. */
+enum class Event(val id: String, val title: String, val short: String, val puzzle: Puzzle, val wca: Boolean = true) {
+ E333("333", "3x3x3", "3x3", Puzzle.CUBE3),
+ E222("222", "2x2x2", "2x2", Puzzle.CUBE2),
+ E444("444", "4x4x4", "4x4", Puzzle.CUBE4),
+ E555("555", "5x5x5", "5x5", Puzzle.CUBE5),
+ E666("666", "6x6x6", "6x6", Puzzle.CUBE6),
+ E777("777", "7x7x7", "7x7", Puzzle.CUBE7),
+ E333BF("333bf", "3x3x3 Blindfolded", "3BLD", Puzzle.CUBE3),
+ E333FM("333fm", "3x3x3 Fewest Moves", "FMC", Puzzle.CUBE3),
+ E333OH("333oh", "3x3x3 One-Handed", "OH", Puzzle.CUBE3),
+ CLOCK("clock", "Clock", "Clock", Puzzle.CLOCK),
+ MINX("minx", "Megaminx", "Mega", Puzzle.MEGAMINX),
+ PYRAM("pyram", "Pyraminx", "Pyra", Puzzle.PYRAMINX),
+ SKEWB("skewb", "Skewb", "Skewb", Puzzle.SKEWB),
+ SQ1("sq1", "Square-1", "Sq-1", Puzzle.SQUARE1),
+ E444BF("444bf", "4x4x4 Blindfolded", "4BLD", Puzzle.CUBE4),
+ E555BF("555bf", "5x5x5 Blindfolded", "5BLD", Puzzle.CUBE5),
+ E333MBF("333mbf", "3x3x3 Multi-Blind", "MBLD", Puzzle.CUBE3),
+ FTO("fto", "Face-Turning Octahedron", "FTO", Puzzle.FTO, wca = false);
+
+ val blind: Boolean get() = this == E333BF || this == E444BF || this == E555BF || this == E333MBF
+
+ companion object {
+ fun byId(id: String): Event? = entries.firstOrNull { it.id == id }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt
new file mode 100644
index 0000000..923b8da
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt
@@ -0,0 +1,19 @@
+package coffee.liz.rookery.model
+
+/** `12.34`, `1:02.34`, or `DNF`. Singles truncate to centiseconds, like the WCA does. */
+fun formatTime(ms: Long?, round: Boolean = false): String {
+ if (ms == null) return "-"
+ if (ms == DNF) return "DNF"
+ val cs = if (round) (ms + 5) / 10 else ms / 10
+ val minutes = cs / 6000
+ val seconds = (cs / 100) % 60
+ val hundredths = cs % 100
+ val tail = "." + hundredths.toString().padStart(2, '0')
+ return if (minutes > 0) "$minutes:" + seconds.toString().padStart(2, '0') + tail else "$seconds$tail"
+}
+
+fun formatSolve(s: Solve): String = when (s.penalty) {
+ Penalty.NONE -> formatTime(s.ms)
+ Penalty.PLUS2 -> formatTime(s.ms + 2000) + "+"
+ Penalty.DNF -> "DNF(" + formatTime(s.ms) + ")"
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt
new file mode 100644
index 0000000..9bda5f7
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt
@@ -0,0 +1,60 @@
+package coffee.liz.rookery.puzzle
+
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+data class V3(val x: Double, val y: Double, val z: Double) {
+ operator fun plus(o: V3) = V3(x + o.x, y + o.y, z + o.z)
+ operator fun minus(o: V3) = V3(x - o.x, y - o.y, z - o.z)
+ operator fun times(k: Double) = V3(x * k, y * k, z * k)
+ infix fun dot(o: V3) = x * o.x + y * o.y + z * o.z
+ infix fun cross(o: V3) = V3(y * o.z - z * o.y, z * o.x - x * o.z, x * o.y - y * o.x)
+ val length: Double get() = sqrt(this dot this)
+ fun unit(): V3 = this * (1.0 / length)
+
+ /** Rotate about unit axis [k] by [angle] radians, right-handed (counter-clockwise seen from +k). */
+ fun rotate(k: V3, angle: Double): V3 {
+ val c = cos(angle); val s = sin(angle)
+ return this * c + (k cross this) * s + k * ((k dot this) * (1 - c))
+ }
+
+ companion object { val ZERO = V3(0.0, 0.0, 0.0) }
+}
+
+/** A convex planar polygon, vertices counter-clockwise seen from outside. */
+class Polygon(val points: List<V3>) {
+ val centroid: V3 = points.fold(V3.ZERO) { a, p -> a + p } * (1.0 / points.size)
+ val normal: V3 get() = ((points[1] - points[0]) cross (points[2] - points[0])).unit()
+ val area: Double get() {
+ var acc = V3.ZERO
+ for (i in points.indices) acc += points[i] cross points[(i + 1) % points.size]
+ return acc.length / 2
+ }
+
+ /** Sutherland–Hodgman split by the plane `dot(p, n) = c`: (part above, part below), either may be null. */
+ fun split(n: V3, c: Double, eps: Double = 1e-9): Pair<Polygon?, Polygon?> {
+ val above = mutableListOf<V3>()
+ val below = mutableListOf<V3>()
+ for (i in points.indices) {
+ val a = points[i]; val b = points[(i + 1) % points.size]
+ val da = (a dot n) - c; val db = (b dot n) - c
+ if (da >= -eps) above += a
+ if (da <= eps) below += a
+ if ((da > eps && db < -eps) || (da < -eps && db > eps)) {
+ val p = a + (b - a) * (da / (da - db))
+ above += p; below += p
+ }
+ }
+ fun mk(ps: List<V3>) = if (ps.size >= 3) Polygon(ps).takeIf { it.area > 1e-7 } else null
+ return mk(above) to mk(below)
+ }
+
+ fun map(f: (V3) -> V3) = Polygon(points.map(f))
+
+ /** Shrink toward the centroid, for drawing stickers with plastic showing between them. */
+ fun inset(k: Double) = map { centroid + (it - centroid) * k }
+}
+
+internal fun approxEq(a: V3, b: V3, eps: Double = 1e-6) = abs(a.x - b.x) < eps && abs(a.y - b.y) < eps && abs(a.z - b.z) < eps
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt
new file mode 100644
index 0000000..54b4cda
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt
@@ -0,0 +1,128 @@
+package coffee.liz.rookery.puzzle
+
+/**
+ * Sticker-level model of an NxN cube, enough to draw scramble previews and replay notation.
+ *
+ * Faces are stored U R F D L B (Kociemba order), each n*n stickers row-major as you look at the
+ * face on a standard net. Geometry uses doubled integer coordinates: cubie centres sit on odd/even
+ * steps in [-(n-1), n-1] and stickers poke out to ±n, so quarter turns stay exact integers.
+ * Axes: x→R, y→U, z→F.
+ */
+class NxnCube(val n: Int) {
+ init { require(n in 1..12) }
+
+ enum class Face { U, R, F, D, L, B }
+
+ /** Colour index per sticker slot; the colour index is the face it started on. */
+ val stickers = IntArray(6 * n * n) { it / (n * n) }
+
+ val isSolved: Boolean get() = (0 until 6).all { f -> (0 until n * n).all { stickers[f * n * n + it] == f } }
+
+ fun copy(): NxnCube = NxnCube(n).also { stickers.copyInto(it.stickers) }
+
+ fun apply(alg: String): NxnCube { for (m in Notation.parse(alg, n)) turn(m); return this }
+
+ fun turn(m: Move) {
+ val perm = permutation(m.axis, m.layers, m.quarter)
+ val old = stickers.copyOf()
+ for (i in perm.indices) stickers[perm[i]] = old[i]
+ }
+
+ // ---- geometry ----
+
+ private class P(val x: Int, val y: Int, val z: Int)
+
+ private val positions: Array<P> = Array(6 * n * n) { slotPosition(it) }
+ private val index: Map<Triple<Int, Int, Int>, Int> = positions.withIndex().associate { (i, p) -> Triple(p.x, p.y, p.z) to i }
+
+ private fun slotPosition(slot: Int): P {
+ val face = Face.entries[slot / (n * n)]
+ val r = (slot % (n * n)) / n
+ val c = slot % n
+ val a = 2 * c - (n - 1) // left→right
+ val b = 2 * r - (n - 1) // top→bottom
+ return when (face) {
+ Face.U -> P(a, n, b)
+ Face.D -> P(a, -n, -b)
+ Face.F -> P(a, -b, n)
+ Face.B -> P(-a, -b, -n)
+ Face.R -> P(n, -b, -a)
+ Face.L -> P(-n, -b, a)
+ }
+ }
+
+ private val cache = HashMap<Triple<Int, Set<Int>, Int>, IntArray>()
+
+ /**
+ * Where each slot goes for a turn of [layers] (0 = the +axis face layer, n-1 = the -axis face
+ * layer) about [axis] (0=x,1=y,2=z), clockwise looking from +axis, [quarter] times (1..3).
+ */
+ private fun permutation(axis: Int, layers: Set<Int>, quarter: Int): IntArray = cache.getOrPut(Triple(axis, layers, quarter)) {
+ IntArray(positions.size) { i ->
+ var p = positions[i]
+ val coord = when (axis) { 0 -> p.x; 1 -> p.y; else -> p.z }.coerceIn(-(n - 1), n - 1)
+ val layer = ((n - 1) - coord) / 2
+ if (layer in layers) repeat(quarter) { p = rotate(p, axis) }
+ index.getValue(Triple(p.x, p.y, p.z))
+ }
+ }
+
+ private fun rotate(p: P, axis: Int): P = when (axis) {
+ 0 -> P(p.x, p.z, -p.y)
+ 1 -> P(-p.z, p.y, p.x)
+ else -> P(p.y, -p.x, p.z)
+ }
+
+ fun face(f: Face): IntArray = stickers.copyOfRange(f.ordinal * n * n, (f.ordinal + 1) * n * n)
+}
+
+/** A turn of a set of layers about an axis, clockwise from the +axis side. */
+data class Move(val axis: Int, val layers: Set<Int>, val quarter: Int)
+
+object Notation {
+ private val token = Regex("""^(\d*)([URFDLBurfdlbxyzMES])(w?)(\d*)('?)$""")
+
+ /** Parse WCA/SiGN notation (R, U2', Rw, 3Rw, r, 2R, x, M…) into layer turns for an n-cube. */
+ fun parse(alg: String, n: Int): List<Move> = alg
+ .replace("(", " ").replace(")", " ")
+ .split(Regex("\\s+")).filter { it.isNotEmpty() }
+ .map { parseMove(it, n) ?: throw IllegalArgumentException("bad move '$it' for ${n}x$n") }
+
+ fun parseMove(t: String, n: Int): Move? {
+ val m = token.matchEntire(t) ?: return null
+ val (prefix, letter, w, amountStr, prime) = m.destructured
+ var amount = if (amountStr.isEmpty()) 1 else amountStr.toInt()
+ if (prime.isNotEmpty()) amount = -amount
+ val all = (0 until n).toSet()
+ val lower = letter[0].isLowerCase() && letter[0] !in "xyz"
+ val face = letter.uppercase()[0]
+ val depth: IntRange = when {
+ face in "XYZ" -> 0 until n
+ face in "MES" -> if (n % 2 == 1 && n > 1) (n / 2)..(n / 2) else return null
+ w.isNotEmpty() || lower -> 0 until (if (prefix.isEmpty()) 2 else prefix.toInt())
+ prefix.isNotEmpty() -> (prefix.toInt() - 1)..(prefix.toInt() - 1)
+ else -> 0..0
+ }
+ if (depth.last >= n) return null
+ // axis, and whether this face sits on the -axis side (so layers count from the other end and direction flips)
+ val (axis, negative) = when (face) {
+ 'R', 'X' -> 0 to false; 'L', 'M' -> 0 to true
+ 'U', 'Y' -> 1 to false; 'D', 'E' -> 1 to true
+ 'F', 'Z', 'S' -> 2 to false; 'B' -> 2 to true
+ else -> return null
+ }
+ val layers = if (face in "XYZ") all else depth.map { if (negative) n - 1 - it else it }.toSet()
+ val q = ((if (negative) -amount else amount) % 4 + 4) % 4
+ return Move(axis, layers, if (q == 0) 4 else q)
+ }
+
+ /** Inverse of a move sequence, as text. */
+ fun invert(alg: String): String = alg.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.reversed().joinToString(" ") { t ->
+ when {
+ t.endsWith("2'") -> t.dropLast(1)
+ t.endsWith("2") -> t
+ t.endsWith("'") -> t.dropLast(1)
+ else -> "$t'"
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt
new file mode 100644
index 0000000..3b54f4b
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt
@@ -0,0 +1,102 @@
+package coffee.liz.rookery.puzzle
+
+import coffee.liz.rookery.model.Puzzle
+import kotlin.math.sqrt
+
+/** Ready-made [TwistyPuzzle]s, each matching tnoodle's (WCA) orientation and notation. */
+object Puzzles {
+ private val cache = HashMap<Puzzle, TwistyPuzzle?>()
+
+ /** The 3D model for [p], or null if it has none yet (Square-1, Clock, FTO, Megaminx). */
+ fun of(p: Puzzle): TwistyPuzzle? = cache.getOrPut(p) {
+ when (p) {
+ Puzzle.CUBE2, Puzzle.CUBE3, Puzzle.CUBE4, Puzzle.CUBE5, Puzzle.CUBE6, Puzzle.CUBE7 -> cube(p.cubeSize!!)
+ Puzzle.PYRAMINX -> pyraminx()
+ Puzzle.SKEWB -> skewb()
+ else -> null
+ }
+ }
+
+ /**
+ * Face colours as 0xRRGGBB, indexed like each puzzle's faces.
+ * Cubes and Skewb: U R F D L B = white red green yellow orange blue (WCA scheme).
+ * Pyraminx: F L R D = green red blue yellow.
+ */
+ fun colors(p: Puzzle): List<Long> = when (p) {
+ Puzzle.PYRAMINX -> listOf(0x2DB84D, 0xE02424, 0x2266E0, 0xFFD500)
+ else -> listOf(0xF4F4F4, 0xE02424, 0x2DB84D, 0xFFD500, 0xFF8A00, 0x2266E0)
+ }.map { it.toLong() }
+
+ private fun tokens(alg: String) = alg.replace("(", " ").replace(")", " ").split(Regex("\\s+")).filter { it.isNotEmpty() }
+
+ // ---- NxN ----
+
+ fun cube(n: Int): TwistyPuzzle {
+ val v = { x: Int, y: Int, z: Int -> V3(x.toDouble(), y.toDouble(), z.toDouble()) }
+ // U R F D L B, counter-clockwise seen from outside
+ val faces = listOf(
+ Polygon(listOf(v(-1, 1, -1), v(-1, 1, 1), v(1, 1, 1), v(1, 1, -1))),
+ Polygon(listOf(v(1, 1, 1), v(1, -1, 1), v(1, -1, -1), v(1, 1, -1))),
+ Polygon(listOf(v(-1, 1, 1), v(-1, -1, 1), v(1, -1, 1), v(1, 1, 1))),
+ Polygon(listOf(v(-1, -1, 1), v(-1, -1, -1), v(1, -1, -1), v(1, -1, 1))),
+ Polygon(listOf(v(-1, 1, -1), v(-1, -1, -1), v(-1, -1, 1), v(-1, 1, 1))),
+ Polygon(listOf(v(1, 1, -1), v(1, -1, -1), v(-1, -1, -1), v(-1, 1, -1))),
+ )
+ val cuts = (1 until n).map { -1.0 + 2.0 * it / n }
+ val axes = listOf(Axis("x", v(1, 0, 0), cuts, 4), Axis("y", v(0, 1, 0), cuts, 4), Axis("z", v(0, 0, 1), cuts, 4))
+ // NxnCube's Notation already speaks "axis 0/1/2, layer 0 = + side, quarter turns clockwise from +axis".
+ return TwistyPuzzle("${n}x$n", faces, axes) { alg ->
+ Notation.parse(alg, n).map { Turn(it.axis, it.layers, it.quarter % 4) }
+ }
+ }
+
+ // ---- Pyraminx: vertex U up, face F towards you ----
+
+ fun pyraminx(): TwistyPuzzle {
+ val r = 2 * sqrt(2.0) / 3
+ val u = V3(0.0, 1.0, 0.0)
+ val l = V3(-r * sqrt(3.0) / 2, -1.0 / 3, r / 2)
+ val rr = V3(r * sqrt(3.0) / 2, -1.0 / 3, r / 2)
+ val b = V3(0.0, -1.0 / 3, -r)
+ val faces = listOf(oriented(u, l, rr), oriented(u, b, l), oriented(u, rr, b), oriented(l, b, rr))
+ // Opposite faces sit at -1/3 along each vertex axis; thirds of the height give tips and middles.
+ val cuts = listOf(1.0 / 9, 5.0 / 9)
+ val axes = listOf(Axis("U", u, cuts, 3), Axis("L", l, cuts, 3), Axis("R", rr, cuts, 3), Axis("B", b, cuts, 3))
+ return TwistyPuzzle("pyraminx", faces, axes) { alg ->
+ tokens(alg).map { t ->
+ val m = Regex("""^([ULRBulrb])(')?$""").matchEntire(t) ?: throw IllegalArgumentException("bad pyraminx move '$t'")
+ val c = m.groupValues[1][0]
+ val axis = "ULRB".indexOf(c.uppercaseChar())
+ Turn(axis, if (c.isLowerCase()) setOf(0) else setOf(0, 1), if (m.groupValues[2].isEmpty()) 1 else 2)
+ }
+ }
+ }
+
+ // ---- Skewb (WCA notation: R = DRB corner, U = ULB, L = DLF, B = DLB) ----
+
+ fun skewb(): TwistyPuzzle {
+ val cubeFaces = cube(1)
+ val k = 1 / sqrt(3.0)
+ fun corner(x: Int, y: Int, z: Int) = V3(x * k, y * k, z * k)
+ val axes = listOf(
+ Axis("R", corner(1, -1, -1), listOf(0.0), 3),
+ Axis("U", corner(-1, 1, -1), listOf(0.0), 3),
+ Axis("L", corner(-1, -1, 1), listOf(0.0), 3),
+ Axis("B", corner(-1, -1, -1), listOf(0.0), 3),
+ )
+ return TwistyPuzzle("skewb", cubeFaces.faces(), axes) { alg ->
+ tokens(alg).map { t ->
+ val m = Regex("""^([RULB])(')?$""").matchEntire(t) ?: throw IllegalArgumentException("bad skewb move '$t'")
+ Turn("RULB".indexOf(m.groupValues[1][0]), setOf(0), if (m.groupValues[2].isEmpty()) 1 else 2)
+ }
+ }
+ }
+
+ /** Re-derive whole (uncut) faces from a 1x1 model. */
+ private fun TwistyPuzzle.faces(): List<Polygon> = stickers
+
+ private fun oriented(a: V3, b: V3, c: V3): Polygon {
+ val p = Polygon(listOf(a, b, c))
+ return if ((p.normal dot p.centroid) > 0) p else Polygon(listOf(a, c, b))
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt
new file mode 100644
index 0000000..cdb8d85
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt
@@ -0,0 +1,91 @@
+package coffee.liz.rookery.puzzle
+
+import kotlin.math.PI
+
+/**
+ * A turning axis: unit [normal], parallel cut planes at signed distances [cuts], and the rotational
+ * order of a turn (4 for cubes, 3 for tetra/octa/corner-turners, 5 for dodecahedra).
+ *
+ * Slabs are numbered from the +normal side: slab 0 lies beyond the largest cut.
+ */
+class Axis(val name: String, val normal: V3, cuts: List<Double>, val order: Int) {
+ val cuts: List<Double> = cuts.sortedDescending()
+ fun slabOf(p: V3): Int { val d = p dot normal; return cuts.count { it > d } }
+ val slabs: Int get() = cuts.size + 1
+}
+
+/** Rotate [layers] of [axis] by [amount] steps of 2π/order, clockwise as seen from the +normal side. */
+data class Turn(val axis: Int, val layers: Set<Int>, val amount: Int)
+
+/**
+ * Any "polyhedron cut by planes" twisty puzzle. Stickers are the face polygons clipped by every
+ * cut plane; a turn permutes sticker slots. State is just a colour per slot (see [PuzzleState]).
+ */
+class TwistyPuzzle(
+ val name: String,
+ faces: List<Polygon>,
+ val axes: List<Axis>,
+ /** Parses this puzzle's notation into turns; throws on unknown moves. */
+ private val notation: (String) -> List<Turn>,
+) {
+ val stickers: List<Polygon>
+ /** Which original face (= colour index) each slot belongs to when solved. */
+ val stickerFace: IntArray
+ val faceCount: Int = faces.size
+
+ init {
+ val out = mutableListOf<Polygon>()
+ val faceOf = mutableListOf<Int>()
+ for ((fi, face) in faces.withIndex()) {
+ var parts = listOf(face)
+ for (axis in axes) for (c in axis.cuts) {
+ parts = parts.flatMap { p -> p.split(axis.normal, c).toList().filterNotNull() }
+ }
+ out += parts; repeat(parts.size) { faceOf += fi }
+ }
+ stickers = out
+ stickerFace = faceOf.toIntArray()
+ }
+
+ private val slab: Array<IntArray> = Array(axes.size) { a -> IntArray(stickers.size) { axes[a].slabOf(stickers[it].centroid) } }
+
+ fun slabOf(axis: Int, sticker: Int): Int = slab[axis][sticker]
+
+ private val perms = HashMap<Turn, IntArray>()
+
+ /** perm[i] = slot that the sticker at slot i moves to. */
+ fun permutation(t: Turn): IntArray = perms.getOrPut(t) {
+ val axis = axes[t.axis]
+ val angle = -2 * PI * t.amount / axis.order
+ IntArray(stickers.size) { i ->
+ if (slab[t.axis][i] !in t.layers) i
+ else {
+ val target = stickers[i].centroid.rotate(axis.normal, angle)
+ var best = -1; var bestD = Double.MAX_VALUE
+ for (j in stickers.indices) {
+ val d = (stickers[j].centroid - target).length
+ if (d < bestD) { bestD = d; best = j }
+ }
+ check(bestD < 1e-6) { "$name: turn $t doesn't map sticker $i onto a sticker (off by $bestD)" }
+ best
+ }
+ }
+ }
+
+ fun parse(alg: String): List<Turn> = notation(alg)
+
+ fun solved(): PuzzleState = PuzzleState(this, stickerFace.copyOf())
+}
+
+class PuzzleState(val puzzle: TwistyPuzzle, val colors: IntArray) {
+ fun apply(t: Turn): PuzzleState {
+ val p = puzzle.permutation(t)
+ val next = IntArray(colors.size)
+ for (i in colors.indices) next[p[i]] = colors[i]
+ return PuzzleState(puzzle, next)
+ }
+
+ fun apply(alg: String): PuzzleState = puzzle.parse(alg).fold(this) { s, t -> s.apply(t) }
+
+ val isSolved: Boolean get() = colors.contentEquals(puzzle.stickerFace)
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt
new file mode 100644
index 0000000..b056e91
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt
@@ -0,0 +1,14 @@
+package coffee.liz.rookery.scramble
+
+import coffee.liz.rookery.model.Event
+
+interface Scrambler {
+ /** A fresh scramble. May block while solver tables warm up; call off the UI thread. */
+ fun next(event: Event): String
+
+ /** An SVG drawing of the scrambled puzzle, or null if unavailable. */
+ fun previewSvg(event: Event, scramble: String): String?
+
+ /** Start building solver tables / a scramble ahead of time. */
+ fun warm(event: Event) {}
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt
new file mode 100644
index 0000000..59a2a93
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt
@@ -0,0 +1,95 @@
+package coffee.liz.rookery.stats
+
+import coffee.liz.rookery.model.DNF
+
+/** Averages the way the WCA (and csTimer) compute them. Inputs are results in ms, [DNF] for DNFs. */
+object Averages {
+ /** Solves trimmed from each end of an average of [n]: 1 for ao5/ao12, 5% beyond that. */
+ fun trim(n: Int): Int = if (n < 5) 0 else maxOf(1, (n + 19) / 20)
+
+ /** Trimmed mean of a window of results; DNF if more DNFs than the trim allows. */
+ fun average(window: List<Long>): Long {
+ if (window.isEmpty()) return DNF
+ val sorted = window.sorted()
+ return trimmedMean(sorted, trim(window.size))
+ }
+
+ /** Plain mean; any DNF makes it DNF. Used for mo3. */
+ fun mean(window: List<Long>): Long =
+ if (window.isEmpty() || window.any { it == DNF }) DNF else window.sum() / window.size
+
+ private fun trimmedMean(sorted: List<Long>, t: Int): Long {
+ val kept = sorted.subList(t, sorted.size - t)
+ if (kept.any { it == DNF }) return DNF
+ return (kept.sum() + kept.size / 2) / kept.size
+ }
+
+ /**
+ * Best aoN (or moN when [mean]) over all windows, oldest first. O(len * n), fine up to ao1000
+ * on six-figure solve counts.
+ */
+ fun best(results: List<Long>, n: Int, mean: Boolean = false): Long? {
+ if (results.size < n) return null
+ val window = ArrayList<Long>(n + 1)
+ var best = DNF
+ for ((i, r) in results.withIndex()) {
+ window.add(lowerBound(window, r), r)
+ if (i >= n) window.removeAt(lowerBound(window, results[i - n]))
+ if (i >= n - 1) {
+ val v = if (mean) (if (window.last() == DNF) DNF else window.sum() / n) else trimmedMean(window, trim(n))
+ if (v < best) best = v
+ }
+ }
+ return best
+ }
+
+ private fun lowerBound(xs: List<Long>, v: Long): Int {
+ var lo = 0; var hi = xs.size
+ while (lo < hi) { val m = (lo + hi) ushr 1; if (xs[m] < v) lo = m + 1 else hi = m }
+ return lo
+ }
+}
+
+enum class StatKind(val label: String, val n: Int, val mean: Boolean = false) {
+ SINGLE("single", 1), MO3("mo3", 3, true), AO5("ao5", 5), AO12("ao12", 12), AO50("ao50", 50), AO100("ao100", 100), AO1000("ao1000", 1000);
+}
+
+data class StatLine(val kind: StatKind, val current: Long?, val best: Long?)
+
+/** Summary of a chronological list of results. */
+data class StatSummary(val count: Int, val solved: Int, val mean: Long?, val lines: List<StatLine>) {
+ fun line(k: StatKind): StatLine? = lines.firstOrNull { it.kind == k }
+
+ companion object {
+ fun of(results: List<Long>): StatSummary {
+ val solved = results.filter { it != DNF }
+ val lines = StatKind.entries.mapNotNull { k ->
+ if (results.size < k.n) return@mapNotNull null
+ val last = results.subList(results.size - k.n, results.size)
+ val current = when {
+ k == StatKind.SINGLE -> last[0]
+ k.mean -> Averages.mean(last)
+ else -> Averages.average(last)
+ }
+ val best = if (k == StatKind.SINGLE) results.min() else Averages.best(results, k.n, k.mean)
+ StatLine(k, current, best)
+ }
+ return StatSummary(results.size, solved.size, if (solved.isEmpty()) null else solved.sum() / solved.size, lines)
+ }
+ }
+}
+
+/** A personal best set by the most recent solve. */
+data class PersonalBest(val kind: StatKind, val value: Long, val previous: Long?)
+
+/**
+ * PBs earned by appending one solve: the new current value strictly beats the best from before.
+ * Only meaningful right after an add; edits to old solves don't throw parties.
+ */
+fun newPersonalBests(before: StatSummary, after: StatSummary): List<PersonalBest> =
+ after.lines.mapNotNull { line ->
+ val cur = line.current ?: return@mapNotNull null
+ if (cur == DNF) return@mapNotNull null
+ val prev = before.line(line.kind)?.best
+ if (prev == null || cur < prev) PersonalBest(line.kind, cur, prev) else null
+ }
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt
new file mode 100644
index 0000000..c4750ed
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt
@@ -0,0 +1,39 @@
+package coffee.liz.rookery.store
+
+/**
+ * Hybrid logical clock timestamp. Totally ordered: wall time, then counter, then node id,
+ * so two devices can never produce equal stamps. The string form sorts the same way.
+ */
+data class Hlc(val wall: Long, val counter: Int, val node: String) : Comparable<Hlc> {
+ override fun compareTo(other: Hlc): Int =
+ compareValuesBy(this, other, Hlc::wall, Hlc::counter, Hlc::node)
+
+ override fun toString(): String =
+ wall.toString().padStart(13, '0') + "-" + counter.toString(16).padStart(4, '0') + "-" + node
+
+ companion object {
+ fun parse(s: String): Hlc {
+ val a = s.indexOf('-')
+ val b = s.indexOf('-', a + 1)
+ require(a > 0 && b > a) { "bad hlc: $s" }
+ return Hlc(s.substring(0, a).toLong(), s.substring(a + 1, b).toInt(16), s.substring(b + 1))
+ }
+ }
+}
+
+class HlcClock(private val node: String, private val now: () -> Long) {
+ private var last = Hlc(0, 0, node)
+
+ fun tick(): Hlc {
+ val wall = now()
+ last = if (wall > last.wall) Hlc(wall, 0, node) else Hlc(last.wall, last.counter + 1, node)
+ return last
+ }
+
+ /** Fold in a remote stamp so our next tick sorts after everything we have seen. */
+ fun observe(remote: Hlc) {
+ if (remote.wall > last.wall || (remote.wall == last.wall && remote.counter > last.counter)) {
+ last = Hlc(remote.wall, remote.counter, node)
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt
new file mode 100644
index 0000000..5c74fb0
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt
@@ -0,0 +1,26 @@
+package coffee.liz.rookery.store
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+
+/**
+ * One last-writer-wins assignment: collection/id/field := value, stamped with an HLC.
+ * Every piece of synced state is built from these, which makes merging a plain union:
+ * apply every op from every device in any order and all replicas converge.
+ *
+ * Conventions:
+ * - `_del` = true tombstones an entity.
+ * - Set-like data uses one field per element (e.g. `w:penguin` = true/false).
+ */
+@Serializable
+data class Op(val t: String, val c: String, val id: String, val f: String, val v: JsonElement) {
+ val hlc: Hlc get() = Hlc.parse(t)
+
+ fun encode(): String = json.encodeToString(serializer(), this)
+
+ companion object {
+ val json = Json { ignoreUnknownKeys = true }
+ fun decode(line: String): Op? = runCatching { json.decodeFromString(serializer(), line) }.getOrNull()
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt
new file mode 100644
index 0000000..46685fb
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt
@@ -0,0 +1,32 @@
+package coffee.liz.rookery.store
+
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+
+/** Materialized LWW state: collection -> id -> field -> (stamp, value). */
+class Replica {
+ class Cell(val t: String, val v: JsonElement)
+
+ private val data = HashMap<String, HashMap<String, HashMap<String, Cell>>>()
+
+ /** Returns true if the op changed visible state. */
+ fun apply(op: Op): Boolean {
+ val fields = data.getOrPut(op.c) { HashMap() }.getOrPut(op.id) { HashMap() }
+ val cur = fields[op.f]
+ // Stamps are fixed-width-prefixed strings that sort like the Hlc itself.
+ if (cur != null && Hlc.parse(cur.t) >= op.hlc) return false
+ fields[op.f] = Cell(op.t, op.v)
+ return true
+ }
+
+ /** Live (non-tombstoned) entities in a collection. */
+ fun entities(c: String): Map<String, Map<String, JsonElement>> =
+ data[c].orEmpty()
+ .filterValues { f -> (f["_del"]?.v as? JsonPrimitive)?.booleanOrNull != true }
+ .mapValues { (_, f) -> f.mapValues { it.value.v } }
+
+ fun entity(c: String, id: String): Map<String, JsonElement>? = entities(c)[id]
+
+ fun fieldStamp(c: String, id: String, f: String): String? = data[c]?.get(id)?.get(f)?.t
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
new file mode 100644
index 0000000..6c0fafa
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
@@ -0,0 +1,166 @@
+package coffee.liz.rookery.store
+
+import coffee.liz.rookery.model.CubeDevice
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.LetterPair
+import coffee.liz.rookery.model.MemoSession
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Session
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.model.WordChange
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.longOrNull
+
+/** Collection names in the op log. Renaming one is a data migration, so they stay short and fixed. */
+object Coll {
+ const val SESSION = "session"
+ const val SOLVE = "solve"
+ const val PAIR = "pair"
+ const val PAIR_HISTORY = "pairHist"
+ const val MEMO = "memo"
+ const val DEVICE = "device"
+ const val PREFS = "prefs"
+}
+
+private fun Map<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull
+private fun Map<String, JsonElement>.long(f: String): Long? = (this[f] as? JsonPrimitive)?.longOrNull
+private fun Map<String, JsonElement>.bool(f: String): Boolean? = (this[f] as? JsonPrimitive)?.booleanOrNull
+
+/** Typed reads and writes over the [Store]. */
+class Repo(val store: Store) {
+
+ // ---- sessions & solves ----
+
+ fun sessions(): List<Session> = store.read { r ->
+ r.entities(Coll.SESSION).mapNotNull { (id, f) ->
+ val event = Event.byId(f.str("event") ?: return@mapNotNull null) ?: return@mapNotNull null
+ Session(id, f.str("name") ?: event.short, event, f.long("created") ?: 0)
+ }.sortedBy { it.createdAt }
+ }
+
+ fun solves(): List<Solve> = store.read { r ->
+ r.entities(Coll.SOLVE).mapNotNull { (id, f) ->
+ Solve(
+ id = id,
+ sessionId = f.str("session") ?: return@mapNotNull null,
+ event = Event.byId(f.str("event") ?: return@mapNotNull null) ?: return@mapNotNull null,
+ ms = f.long("ms") ?: return@mapNotNull null,
+ penalty = Penalty.of(f.str("pen")),
+ scramble = f.str("scr").orEmpty(),
+ comment = f.str("note").orEmpty(),
+ at = f.long("at") ?: 0,
+ moves = f.str("moves"),
+ )
+ }.sortedWith(compareBy({ it.at }, { it.id }))
+ }
+
+ fun createSession(id: String, name: String, event: Event, createdAt: Long) = store.transact {
+ set(Coll.SESSION, id, "name", name)
+ set(Coll.SESSION, id, "event", event.id)
+ set(Coll.SESSION, id, "created", createdAt)
+ }
+
+ fun renameSession(id: String, name: String) = store.transact { set(Coll.SESSION, id, "name", name) }
+
+ fun deleteSession(id: String) {
+ val doomed = solves().filter { it.sessionId == id }
+ store.transact {
+ delete(Coll.SESSION, id)
+ for (s in doomed) delete(Coll.SOLVE, s.id)
+ }
+ }
+
+ fun addSolve(s: Solve) = store.transact { writeSolve(s) }
+
+ fun Store.Tx.writeSolve(s: Solve) {
+ set(Coll.SOLVE, s.id, "session", s.sessionId)
+ set(Coll.SOLVE, s.id, "event", s.event.id)
+ set(Coll.SOLVE, s.id, "ms", s.ms)
+ set(Coll.SOLVE, s.id, "pen", s.penalty.code)
+ set(Coll.SOLVE, s.id, "scr", s.scramble)
+ if (s.comment.isNotEmpty()) set(Coll.SOLVE, s.id, "note", s.comment)
+ set(Coll.SOLVE, s.id, "at", s.at)
+ if (s.moves != null) set(Coll.SOLVE, s.id, "moves", s.moves)
+ }
+
+ fun setPenalty(id: String, p: Penalty) = store.transact { set(Coll.SOLVE, id, "pen", p.code) }
+ fun setComment(id: String, note: String) = store.transact { set(Coll.SOLVE, id, "note", note) }
+ fun deleteSolve(id: String) = store.transact { delete(Coll.SOLVE, id) }
+
+ // ---- letter pairs ----
+
+ fun letterPairs(): Map<String, LetterPair> = store.read { r ->
+ r.entities(Coll.PAIR).mapValues { (pair, f) ->
+ LetterPair(
+ pair = pair,
+ primary = f.str("primary"),
+ words = f.filter { (k, v) -> k.startsWith("w:") && (v as? JsonPrimitive)?.booleanOrNull == true }
+ .keys.map { it.removePrefix("w:") }.toSet(),
+ note = f.str("note"),
+ image = f.str("image"),
+ difficulty = difficulty(f["gymStats"]),
+ )
+ }
+ }
+
+ /** Make [word] the main word for [pair], keeping it in the pair's word set. Logs the change. */
+ fun setPrimaryWord(pair: String, word: String, at: Long, source: String = "edit") {
+ val old = letterPairs()[pair]?.primary
+ if (old == word) return
+ store.transact {
+ set(Coll.PAIR, pair, "primary", word)
+ set(Coll.PAIR, pair, "w:$word", true)
+ set(Coll.PAIR_HISTORY, coffee.liz.rookery.util.fnv64(pair, old.orEmpty(), word, at.toString()), "v",
+ historyJson(WordChange(pair, old, word, at, source)))
+ }
+ }
+
+ fun setPairNote(pair: String, note: String?) = store.transact { set(Coll.PAIR, pair, "note", note) }
+ fun setPairImage(pair: String, blob: String?) = store.transact { set(Coll.PAIR, pair, "image", blob) }
+ fun setWordInSet(pair: String, word: String, present: Boolean) = store.transact { set(Coll.PAIR, pair, "w:$word", present) }
+
+ fun wordHistory(): List<WordChange> = store.read { r ->
+ r.entities(Coll.PAIR_HISTORY).values.mapNotNull { f -> f["v"]?.let(::parseHistory) }
+ }.sortedByDescending { it.at }
+
+ fun memoSessions(): List<MemoSession> = store.read { r ->
+ r.entities(Coll.MEMO).values.mapNotNull { f ->
+ MemoSession(f.long("at") ?: return@mapNotNull null, f.long("pairs")?.toInt() ?: 0, f.long("ms") ?: 0, f.long("ok")?.toInt() ?: 0)
+ }
+ }.sortedBy { it.at }
+
+ // ---- devices & prefs ----
+
+ fun devices(): List<CubeDevice> = store.read { r ->
+ r.entities(Coll.DEVICE).map { (mac, f) -> CubeDevice(mac, f.str("name") ?: mac, f.str("proto") ?: "unknown") }
+ }
+
+ fun pref(key: String): String? = store.read { r -> r.entity(Coll.PREFS, "global")?.str(key) }
+ fun boolPref(key: String, default: Boolean): Boolean =
+ store.read { r -> r.entity(Coll.PREFS, "global")?.bool(key) } ?: default
+ fun setPref(key: String, value: String?) = store.transact { set(Coll.PREFS, "global", key, value) }
+ fun setPref(key: String, value: Boolean) = store.transact { set(Coll.PREFS, "global", key, value) }
+
+ companion object {
+ /** Share of recent attempts that were slow (half weight) or missed (full weight). */
+ private fun difficulty(e: JsonElement?): Double? {
+ val recent = ((e as? kotlinx.serialization.json.JsonObject)?.get("r") as? JsonPrimitive)?.contentOrNull ?: return null
+ if (recent.isEmpty()) return null
+ return recent.sumOf { c -> when (c) { 'm' -> 1.0; 's' -> 0.5; else -> 0.0 } } / recent.length
+ }
+
+ internal fun historyJson(w: WordChange): JsonElement = kotlinx.serialization.json.buildJsonObject {
+ put("pair", JsonPrimitive(w.pair)); put("from", JsonPrimitive(w.from)); put("to", JsonPrimitive(w.to))
+ put("at", JsonPrimitive(w.at)); put("src", JsonPrimitive(w.source))
+ }
+
+ private fun parseHistory(e: JsonElement): WordChange? {
+ val o = e as? kotlinx.serialization.json.JsonObject ?: return null
+ return WordChange(o.str("pair") ?: return null, o.str("from"), o.str("to"), o.long("at") ?: 0, o.str("src") ?: "edit")
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt
new file mode 100644
index 0000000..7049549
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt
@@ -0,0 +1,36 @@
+package coffee.liz.rookery.store
+
+/**
+ * Where op lines live. The contract that makes dumb file sync (Syncthing, Drive, a USB stick) safe:
+ * a device only ever appends to its own log files, and readers tolerate a torn final line.
+ */
+interface LogStorage {
+ /** Append lines (no trailing newlines) to this device's log, durably, in one write. */
+ fun append(lines: List<String>)
+
+ /** Lines from any device's log that this reader has not returned before. */
+ fun readNew(): List<String>
+}
+
+/** Content-addressed binary storage (images), synced alongside the log. */
+interface BlobStore {
+ /** Stores bytes and returns their name, `<sha256>.<ext>`. Idempotent. */
+ fun put(bytes: ByteArray, ext: String): String
+ fun get(name: String): ByteArray?
+}
+
+class MemoryLogStorage(private val shared: MutableList<String> = mutableListOf()) : LogStorage {
+ private var read = 0
+ override fun append(lines: List<String>) { shared.addAll(lines) }
+ override fun readNew(): List<String> = shared.subList(read, shared.size).toList().also { read = shared.size }
+}
+
+class MemoryBlobStore : BlobStore {
+ private val blobs = HashMap<String, ByteArray>()
+ override fun put(bytes: ByteArray, ext: String): String {
+ val name = coffee.liz.rookery.util.fnv64(bytes) + "." + ext
+ blobs[name] = bytes
+ return name
+ }
+ override fun get(name: String) = blobs[name]
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt
new file mode 100644
index 0000000..7873b76
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt
@@ -0,0 +1,61 @@
+package coffee.liz.rookery.store
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+
+/** The synced database: an HLC, a replica, and a log to persist ops to. Thread-confined via [lock]. */
+class Store(
+ val nodeId: String,
+ private val log: LogStorage,
+ val blobs: BlobStore,
+ now: () -> Long,
+) {
+ private val clock = HlcClock(nodeId, now)
+ private val replica = Replica()
+ private val lock = Any()
+ private val _version = MutableStateFlow(0L)
+
+ /** Bumps whenever visible state changes; UI re-derives from the replica when it does. */
+ val version: StateFlow<Long> = _version.asStateFlow()
+
+ init { sync() }
+
+ class Tx internal constructor(private val clock: HlcClock) {
+ internal val ops = mutableListOf<Op>()
+ fun set(c: String, id: String, f: String, v: JsonElement) {
+ ops += Op(clock.tick().toString(), c, id, f, v)
+ }
+ fun set(c: String, id: String, f: String, v: String?) = set(c, id, f, JsonPrimitive(v))
+ fun set(c: String, id: String, f: String, v: Number) = set(c, id, f, JsonPrimitive(v))
+ fun set(c: String, id: String, f: String, v: Boolean) = set(c, id, f, JsonPrimitive(v))
+ fun delete(c: String, id: String) = set(c, id, "_del", true)
+ }
+
+ fun transact(block: Tx.() -> Unit) {
+ coffee.liz.rookery.locked(lock) {
+ val tx = Tx(clock).apply(block)
+ if (tx.ops.isEmpty()) return@locked
+ log.append(tx.ops.map { it.encode() })
+ var changed = false
+ for (op in tx.ops) changed = replica.apply(op) || changed
+ if (changed) _version.value++
+ }
+ }
+
+ /** Pull ops other devices wrote since last time. Returns how many changed state. */
+ fun sync(): Int = coffee.liz.rookery.locked(lock) {
+ var changed = 0
+ for (line in log.readNew()) {
+ val op = Op.decode(line) ?: continue
+ clock.observe(op.hlc)
+ if (replica.apply(op)) changed++
+ }
+ if (changed > 0) _version.value++
+ changed
+ }
+
+ fun <T> read(block: (Replica) -> T): T = coffee.liz.rookery.locked(lock) { block(replica) }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt
new file mode 100644
index 0000000..f08a550
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt
@@ -0,0 +1,80 @@
+package coffee.liz.rookery.timer
+
+import coffee.liz.rookery.model.Penalty
+
+/**
+ * The spacebar/touch timer as a pure state machine. The UI feeds it press/release/tick with a
+ * monotonic clock and renders [state]; nothing in here touches real time.
+ */
+class TimerMachine(var inspection: Boolean = true, var holdMs: Long = 300) {
+ sealed interface State {
+ data object Idle : State
+ data class Inspecting(val since: Long) : State
+ /** Finger down, waiting for the hold time. [inspectingSince] carries over inspection. */
+ data class Holding(val since: Long, val inspectingSince: Long?) : State
+ data class Ready(val inspectingSince: Long?) : State
+ data class Running(val since: Long, val penalty: Penalty) : State
+ data class Stopped(val ms: Long, val penalty: Penalty, val awaitingRelease: Boolean) : State
+ }
+
+ var state: State = State.Idle
+ private set
+
+ /** Fired once per finished solve. */
+ var onFinish: (ms: Long, penalty: Penalty) -> Unit = { _, _ -> }
+
+ fun press(now: Long) {
+ state = when (val s = state) {
+ State.Idle -> State.Holding(now, null)
+ is State.Stopped -> if (s.awaitingRelease) s else State.Holding(now, null)
+ is State.Inspecting -> State.Holding(now, s.since)
+ is State.Running -> {
+ val ms = now - s.since
+ onFinish(ms, s.penalty)
+ State.Stopped(ms, s.penalty, awaitingRelease = true)
+ }
+ is State.Holding, is State.Ready -> s
+ }
+ }
+
+ fun release(now: Long) {
+ state = when (val s = state) {
+ is State.Holding -> when {
+ s.inspectingSince != null -> State.Inspecting(s.inspectingSince) // let go early: keep inspecting
+ inspection -> State.Inspecting(now)
+ else -> State.Idle
+ }
+ is State.Ready -> State.Running(now, inspectionPenalty(s.inspectingSince, now))
+ is State.Stopped -> s.copy(awaitingRelease = false)
+ else -> s
+ }
+ }
+
+ fun tick(now: Long) {
+ val s = state
+ if (s is State.Holding && now - s.since >= holdMs && (s.inspectingSince != null || !inspection)) {
+ state = State.Ready(s.inspectingSince)
+ }
+ if (s is State.Inspecting && now - s.since > INSPECTION_DNF_MS) {
+ onFinish(0, Penalty.DNF)
+ state = State.Stopped(0, Penalty.DNF, awaitingRelease = false)
+ }
+ }
+
+ fun cancel() { state = State.Idle }
+
+ private fun inspectionPenalty(since: Long?, now: Long): Penalty {
+ if (since == null) return Penalty.NONE
+ val t = now - since
+ return when {
+ t > INSPECTION_DNF_MS -> Penalty.DNF
+ t > INSPECTION_MS -> Penalty.PLUS2
+ else -> Penalty.NONE
+ }
+ }
+
+ companion object {
+ const val INSPECTION_MS = 15_000L
+ const val INSPECTION_DNF_MS = 17_000L
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt
new file mode 100644
index 0000000..8c7c447
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt
@@ -0,0 +1,20 @@
+package coffee.liz.rookery.util
+
+/** FNV-1a 64-bit, hex encoded. Used for deterministic ids so re-imports are idempotent. */
+fun fnv64(vararg parts: String): String {
+ var h = -0x340d631b7bdddcdbL // 0xcbf29ce484222325
+ for ((i, p) in parts.withIndex()) {
+ if (i > 0) { h = h xor 0x1f; h *= 0x100000001b3L }
+ for (b in p.encodeToByteArray()) {
+ h = h xor (b.toLong() and 0xff)
+ h *= 0x100000001b3L
+ }
+ }
+ return h.toULong().toString(16).padStart(16, '0')
+}
+
+fun fnv64(bytes: ByteArray): String {
+ var h = -0x340d631b7bdddcdbL
+ for (b in bytes) { h = h xor (b.toLong() and 0xff); h *= 0x100000001b3L }
+ return h.toULong().toString(16).padStart(16, '0')
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
new file mode 100644
index 0000000..7ab195c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
@@ -0,0 +1,14 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.audio.Chime
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class ChimeTest {
+ @Test fun rendersAudibleShortJingle() {
+ val pcm = Chime.render(Chime.PB)
+ val seconds = pcm.size.toDouble() / Chime.SAMPLE_RATE
+ assertTrue(seconds in 0.8..3.0, "length $seconds")
+ assertTrue(pcm.maxOf { it.toInt() } > 3000)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
new file mode 100644
index 0000000..829049e
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
@@ -0,0 +1,55 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Notation
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class CubeTest {
+ @Test fun basicIdentities() {
+ for (n in 2..7) {
+ assertTrue(NxnCube(n).apply("R R R R").isSolved)
+ assertTrue(NxnCube(n).apply("R U R' U' R U R' U' R U R' U' R U R' U' R U R' U' R U R' U'").isSolved, "sexy^6 on $n")
+ assertFalse(NxnCube(n).apply("R U").isSolved)
+ }
+ }
+
+ @Test fun scrambleThenInverseSolves() {
+ val scr = "3Rw2 Uw' F 3Fw D2 Lw' B2 x y' 2R 3Uw2 z M"
+ val c = NxnCube(7).apply(scr)
+ assertFalse(c.isSolved)
+ c.apply(Notation.invert(scr))
+ assertTrue(c.isSolved)
+ }
+
+ @Test fun rMovesFrontToUp() {
+ val c = NxnCube(3).apply("R")
+ // U face right column now shows F's colour (index 2)
+ assertEquals(listOf(0, 0, 2, 0, 0, 2, 0, 0, 2), c.face(NxnCube.Face.U).toList())
+ // F face right column shows D (3)
+ assertEquals(listOf(2, 2, 3, 2, 2, 3, 2, 2, 3), c.face(NxnCube.Face.F).toList())
+ }
+
+ @Test fun uMovesFrontToLeft() {
+ val c = NxnCube(3).apply("U")
+ assertEquals(listOf(5, 5, 5), c.face(NxnCube.Face.R).take(3)) // B comes to R
+ assertEquals(listOf(2, 2, 2), c.face(NxnCube.Face.L).take(3)) // F goes to L
+ }
+
+ @Test fun wideEqualsFacePlusSlice() {
+ val a = NxnCube(5).apply("Rw")
+ val b = NxnCube(5).apply("R 2R")
+ assertContentEquals(a.stickers, b.stickers)
+ assertContentEquals(NxnCube(3).apply("x").stickers, NxnCube(3).apply("R M' L'").stickers)
+ }
+
+ @Test fun tPermIsOrderTwo() {
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ val c = NxnCube(3).apply(t)
+ assertFalse(c.isSolved)
+ assertTrue(c.apply(t).isSolved)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
new file mode 100644
index 0000000..ad7659c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
@@ -0,0 +1,76 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.importer.CsTimerImport
+import coffee.liz.rookery.importer.Importer
+import coffee.liz.rookery.importer.LetterPairGymImport
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ImportTest {
+ private val csTimer = """
+ {"session1":[
+ [[0,114010],"Uw' L2 D L' R2 Dw D' R' F D Bw2 B2 Lw2 D2 Rw' Dw2 Lw' U' Dw' F2 B2 Rw D' Dw2 Lw2 R2 B2 Dw' Fw' Rw B2 Lw2 Uw' D F' Bw2 U Uw Bw2 F' R Dw2 Uw Rw' Dw D2 U Uw Lw' B2 F R' Uw2 B' R Lw2 B Lw L2 B'","",1794373390],
+ [[0,25058],"B' R' F2 U2 L2 F2 U2 B' L2 B2 U2 F' R2 U' F2 L D' L' B L2 F","",1794526179,["U'@0 L'@200","333"]],
+ [[2000,14039],"R U F","nice",1794526518],
+ [[-1,14245],"D2 F","",1794534484]],
+ "session2":[[[0,4000],"R U R' U'","",1794534500]],
+ "properties":{"sessionData":"{\"1\":{\"name\":1,\"opt\":{}},\"2\":{\"name\":\"pyra\",\"opt\":{\"scrType\":\"pyrso\"}}}","giiMacMap":"{\"null\":\"AA:BB:CC:DD:EE:FF\"}"}}
+ """.trimIndent()
+
+ @Test fun parsesCsTimer() {
+ val r = CsTimerImport.parse(csTimer)
+ assertEquals(5, r.solves.size)
+ val events = r.solves.map { it.event }
+ assertEquals(listOf(Event.E555, Event.E333, Event.E333, Event.E333, Event.PYRAM), events)
+ assertEquals(listOf(Penalty.NONE, Penalty.NONE, Penalty.PLUS2, Penalty.DNF), r.solves.take(4).map { it.penalty })
+ assertEquals("U'@0 L'@200", r.solves[1].moves)
+ assertEquals(1794373390_000, r.solves[0].at)
+ assertEquals(3, r.sessions.size)
+ assertTrue(r.sessions.any { it.name == "csTimer 1 (5x5)" })
+ assertEquals("AA:BB:CC:DD:EE:FF", r.devices.single().mac)
+ }
+
+ @Test fun importIsIdempotent() {
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ Importer(repo).csTimer(csTimer)
+ Importer(repo).csTimer(csTimer)
+ assertEquals(5, repo.solves().size)
+ assertEquals(3, repo.sessions().size)
+ assertEquals(1, repo.devices().size)
+ }
+
+ private val gym = """
+ {"app":"letter-pair-gym","v":1,"at":"2026-11-13T03:28:27.903Z",
+ "settings":{"edgeScheme":"ABCDQRSTEFGHIJKLMNOPUVWX","eBuf":2},
+ "words":{"AB":"abacus","AD":"adelie","XW":""},
+ "notes":{"AD":"adelie penguin"},
+ "wordHist":[{"d":1794519658205,"from":"Peg","p":"PG","to":"Postgres"}],
+ "stats":{"AB":{"k":1,"l":[689],"m":5,"ms":61704.39,"n":11,"r":"gsmmsg","s":4}},
+ "memoHist":[{"d":1794505722395,"ms":154339,"ok":8,"p":10}],
+ "solves":[],
+ "images":{"AB":"images/AB - abacus.jpg","AD":"images/AD - missing.jpg"}}
+ """.trimIndent()
+
+ @Test fun importsLetterPairGym() {
+ val parsed = LetterPairGymImport.parse(gym)
+ assertEquals(mapOf("AB" to "abacus", "AD" to "adelie"), parsed.words)
+ assertEquals(listOf(689L), parsed.stats.getValue("AB").latenciesMs)
+
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ val summary = Importer(repo).letterPairGym(gym) { path -> if (path.startsWith("images/AB")) byteArrayOf(1, 2, 3) else null }
+ val pairs = repo.letterPairs()
+ assertEquals("abacus", pairs.getValue("AB").primary)
+ assertEquals("adelie penguin", pairs.getValue("AD").note)
+ assertTrue(pairs.getValue("AB").image!!.endsWith(".jpg"))
+ assertEquals(1, summary.warnings.size)
+ assertEquals("Postgres", repo.wordHistory().single().to)
+ assertEquals(1, repo.memoSessions().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
new file mode 100644
index 0000000..35fc1fc
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
@@ -0,0 +1,54 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.stats.Averages
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.stats.StatSummary
+import coffee.liz.rookery.stats.newPersonalBests
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class StatsTest {
+ @Test fun ao5TrimsBestAndWorst() {
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, 20000)))
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, DNF)))
+ assertEquals(DNF, Averages.average(listOf(10000, 11000, 12000, DNF, DNF)))
+ }
+
+ @Test fun trimCounts() {
+ assertEquals(1, Averages.trim(5)); assertEquals(1, Averages.trim(12))
+ assertEquals(3, Averages.trim(50)); assertEquals(5, Averages.trim(100)); assertEquals(50, Averages.trim(1000))
+ }
+
+ @Test fun mo3AnyDnfIsDnf() {
+ assertEquals(DNF, Averages.mean(listOf(1, 2, DNF)))
+ assertEquals(2, Averages.mean(listOf(1, 2, 3)))
+ }
+
+ @Test fun slidingBestMatchesBruteForce() {
+ val rng = kotlin.random.Random(42)
+ val xs = List(300) { if (rng.nextInt(20) == 0) DNF else rng.nextLong(8000, 20000) }
+ for (n in listOf(5, 12, 100)) {
+ val brute = (0..xs.size - n).minOf { Averages.average(xs.subList(it, it + n)) }
+ assertEquals(brute, Averages.best(xs, n))
+ }
+ assertEquals((0..xs.size - 3).minOf { Averages.mean(xs.subList(it, it + 3)) }, Averages.best(xs, 3, mean = true))
+ }
+
+ @Test fun personalBestsFromLatestSolve() {
+ val before = listOf(15000L, 14000, 16000, 15500, 14500)
+ val after = before + 11000L
+ val pbs = newPersonalBests(StatSummary.of(before), StatSummary.of(after)).map { it.kind }
+ assertEquals(listOf(StatKind.SINGLE, StatKind.MO3, StatKind.AO5), pbs)
+ assertNull(newPersonalBests(StatSummary.of(after), StatSummary.of(after + 30000L)).firstOrNull { it.kind == StatKind.SINGLE })
+ }
+
+ @Test fun formatting() {
+ assertEquals("9.99", formatTime(9999))
+ assertEquals("10.00", formatTime(9999, round = true))
+ assertEquals("1:02.34", formatTime(62345))
+ assertEquals("DNF", formatTime(DNF))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
new file mode 100644
index 0000000..e5fc945
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
@@ -0,0 +1,65 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.store.Hlc
+import coffee.liz.rookery.store.HlcClock
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class StoreTest {
+ @Test fun hlcOrdersAndRoundTrips() {
+ var now = 1000L
+ val c = HlcClock("a") { now }
+ val a = c.tick(); val b = c.tick()
+ assertTrue(b > a)
+ now = 500 // clock went backwards
+ assertTrue(c.tick() > b)
+ c.observe(Hlc(9999, 7, "z"))
+ assertTrue(c.tick() > Hlc(9999, 7, "z"))
+ assertEquals(a, Hlc.parse(a.toString()))
+ assertTrue(Hlc(5, 0, "a").toString() < Hlc(10, 0, "a").toString())
+ }
+
+ private fun solve(id: String, ms: Long) = Solve(id, "s", Event.E333, ms, Penalty.NONE, "R U", "", 1)
+
+ @Test fun twoDevicesConvergeRegardlessOfOrder() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("dev-a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("dev-b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+
+ t = 10; a.addSolve(solve("x", 1000))
+ t = 11; b.addSolve(solve("y", 2000))
+ a.store.sync(); b.store.sync()
+ // concurrent edits to the same field: later stamp wins on both sides
+ t = 20; a.setPenalty("x", Penalty.PLUS2)
+ t = 21; b.setPenalty("x", Penalty.DNF)
+ t = 22; b.deleteSolve("y")
+ a.store.sync(); b.store.sync()
+
+ assertEquals(a.solves(), b.solves())
+ assertEquals(listOf("x"), a.solves().map { it.id })
+ assertEquals(Penalty.DNF, a.solves().single().penalty)
+ }
+
+ @Test fun letterPairWordsMergeAsSet() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ t = 1; a.setPrimaryWord("AB", "abacus", 1)
+ t = 2; b.setPrimaryWord("AB", "abba", 2)
+ a.store.sync(); b.store.sync()
+ val p = a.letterPairs().getValue("AB")
+ assertEquals("abba", p.primary)
+ assertEquals(setOf("abacus", "abba"), p.words)
+ assertEquals(2, a.wordHistory().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
new file mode 100644
index 0000000..2ee2421
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
@@ -0,0 +1,46 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.timer.TimerMachine
+import coffee.liz.rookery.timer.TimerMachine.State
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class TimerTest {
+ @Test fun inspectionFlowWithPlusTwo() {
+ val t = TimerMachine(inspection = true)
+ var result: Pair<Long, Penalty>? = null
+ t.onFinish = { ms, p -> result = ms to p }
+ t.press(0); t.tick(500); t.release(600)
+ assertIs<State.Inspecting>(t.state)
+ t.press(15_500); t.tick(15_700)
+ assertIs<State.Holding>(t.state)
+ t.tick(15_850)
+ assertIs<State.Ready>(t.state)
+ t.release(16_000)
+ assertEquals(State.Running(16_000, Penalty.PLUS2), t.state)
+ t.press(26_000)
+ assertEquals(10_000L to Penalty.PLUS2, result)
+ t.press(26_100) // still holding the stop key: nothing happens
+ assertIs<State.Stopped>(t.state)
+ t.release(26_200); t.press(27_000)
+ assertIs<State.Holding>(t.state)
+ }
+
+ @Test fun releasingEarlyDoesNotStart() {
+ val t = TimerMachine(inspection = false)
+ t.press(0); t.tick(100); t.release(150)
+ assertEquals(State.Idle, t.state)
+ t.press(200); t.tick(600); t.release(700)
+ assertIs<State.Running>(t.state)
+ }
+
+ @Test fun overInspectionIsDnf() {
+ val t = TimerMachine(inspection = true)
+ var p: Penalty? = null
+ t.onFinish = { _, pen -> p = pen }
+ t.press(0); t.release(10); t.tick(17_100)
+ assertEquals(Penalty.DNF, p)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
new file mode 100644
index 0000000..09a1aae
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
@@ -0,0 +1,72 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.puzzle.Notation
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Puzzles
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class TwistyPuzzleTest {
+ @Test fun stickerCounts() {
+ for (n in 2..7) assertEquals(6 * n * n, Puzzles.cube(n).stickers.size, "${n}x$n")
+ assertEquals(36, Puzzles.pyraminx().stickers.size)
+ assertEquals(30, Puzzles.skewb().stickers.size)
+ }
+
+ @Test fun stickersFaceOutwards() {
+ for (p in listOf(Puzzles.cube(3), Puzzles.pyraminx(), Puzzles.skewb())) {
+ for (s in p.stickers) assertTrue((s.normal dot s.centroid) > 0, "${p.name} sticker at ${s.centroid} faces inward")
+ }
+ }
+
+ @Test fun cubeMatchesNxnModelFaceByFace() {
+ val alg = "R U2 F' 2L Dw B2 3Rw' x U' M2 y' S"
+ for (n in listOf(3, 5, 7)) {
+ val generic = Puzzles.cube(n).solved().apply(alg)
+ val reference = NxnCube(n).apply(alg)
+ for (f in NxnCube.Face.entries) {
+ val mine = generic.colors.filterIndexed { i, _ -> generic.puzzle.stickerFace[i] == f.ordinal }.sorted()
+ assertEquals(reference.face(f).sorted(), mine, "${n}x$n face $f")
+ }
+ assertTrue(generic.apply(Notation.invert(alg)).isSolved)
+ }
+ }
+
+ @Test fun cubeMoveOrders() {
+ val c = Puzzles.cube(3).solved()
+ assertTrue(c.apply("R R R R").isSolved)
+ assertTrue(c.apply("R U R' U' ".repeat(6)).isSolved)
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ assertFalse(c.apply(t).isSolved)
+ assertTrue(c.apply("$t $t").isSolved)
+ }
+
+ @Test fun pyraminxMoves() {
+ val p = Puzzles.pyraminx().solved()
+ assertTrue(p.apply("U U U").isSolved)
+ assertTrue(p.apply("u u'").isSolved)
+ assertFalse(p.apply("U").isSolved)
+ // a tip turn only moves the 3 tip stickers
+ assertEquals(3, p.apply("u").colors.indices.count { p.apply("u").colors[it] != p.colors[it] })
+ val scr = "U' L' R B U' L B' U r' b"
+ assertTrue(p.apply(scr).apply("b' r U' B L' U B' R' L U").isSolved)
+ }
+
+ @Test fun skewbMoves() {
+ val s = Puzzles.skewb().solved()
+ assertTrue(s.apply("R R R").isSolved)
+ assertFalse(s.apply("R").isSolved)
+ // a skewb turn moves half the puzzle: 3 faces touch the turning corner fully-ish; count changes
+ val moved = s.apply("R").colors.indices.count { s.apply("R").colors[it] != s.colors[it] }
+ assertEquals(15, moved)
+ assertTrue(s.apply("R U' L B' R' U").apply("U' R B L' U R'").isSolved)
+ }
+
+ @Test fun modelsExistForCubesPyraAndSkewb() {
+ for (p in listOf(Puzzle.CUBE2, Puzzle.CUBE7, Puzzle.PYRAMINX, Puzzle.SKEWB)) assertNotNull(Puzzles.of(p))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
new file mode 100644
index 0000000..5e45af3
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
@@ -0,0 +1,264 @@
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Battery
+import coffee.liz.rookery.bt.SmartCubeEvent.Facelets
+import coffee.liz.rookery.bt.SmartCubeEvent.Gyro
+import coffee.liz.rookery.bt.SmartCubeEvent.Hardware
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+import kotlinx.coroutines.test.runTest
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/** Records commands a driver sends back to the cube. */
+class RecordingRawConnection : GanRawConnection {
+ val sent = mutableListOf<ByteArray>()
+ var disconnects = 0
+ override suspend fun sendCommandMessage(message: ByteArray) { sent += message }
+ override suspend fun disconnect() { disconnects++ }
+}
+
+/** Cube state after F R (from gan-web-bluetooth's docs). */
+val F_R_STATE = CubeState(
+ cp = listOf(0, 5, 2, 1, 7, 4, 6, 3), co = listOf(1, 2, 0, 2, 1, 1, 0, 2),
+ ep = listOf(1, 9, 2, 3, 11, 8, 6, 7, 4, 5, 10, 0), eo = listOf(1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0),
+)
+const val F_R_FACELETS = "UUFUUFLLFUUURRRRRRFFRFFDFFDRRBDDBDDBLLDLLDLLDLBBUBBUBB"
+val SOLVED_STATE = CubeState(List(8) { it }, List(8) { 0 }, List(12) { it }, List(12) { 0 })
+
+/** Writes the first 7 corners / 11 edges of [s] at the given bit offsets, as the cube does. */
+fun GanBitWriter.state(s: CubeState, cpAt: Int, coAt: Int, epAt: Int, eoAt: Int): GanBitWriter {
+ for (i in 0 until 7) { put(cpAt + i * 3, 3, s.cp[i]); put(coAt + i * 2, 2, s.co[i]) }
+ for (i in 0 until 11) { put(epAt + i * 4, 4, s.ep[i]); put(eoAt + i, 1, s.eo[i]) }
+ return this
+}
+
+object Packets {
+ fun gen2Facelets(serial: Int, s: CubeState) = GanBitWriter(20).put(0, 4, 0x4).put(4, 8, serial).state(s, 12, 33, 47, 91).bytes
+
+ /** [moves] newest first: (face 0..5 URFDLB, direction 0/1, elapsed ms since previous move). */
+ fun gen2Moves(serial: Int, moves: List<Triple<Int, Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 4, 0x2).put(4, 8, serial)
+ moves.forEachIndexed { i, (face, dir, elapsed) -> w.put(12 + 5 * i, 4, face).put(16 + 5 * i, 1, dir).put(47 + 16 * i, 16, elapsed) }
+ return w.bytes
+ }
+
+ // Live-move face codes for Gen3/Gen4, indexed by face URFDLB.
+ val LIVE = listOf(2, 32, 8, 1, 16, 4)
+ val HISTORY = listOf(1, 5, 3, 0, 4, 2)
+
+ fun gen3Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0x55).put(8, 8, 0x02).put(16, 8, 0x10).put(24, 16, serial, true).state(s, 40, 61, 77, 121).bytes
+
+ fun gen3Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x01).put(16, 8, 0x08).put(24, 32, cubeTs, true)
+ .put(56, 16, serial, true).put(72, 2, dir).put(74, 6, LIVE[face]).bytes
+
+ fun gen4Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0xED).put(8, 8, 0x10).put(16, 16, serial, true).state(s, 32, 53, 69, 113).bytes
+
+ fun gen4Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(20).put(0, 8, 0x01).put(8, 8, 0x08).put(16, 32, cubeTs, true)
+ .put(48, 16, serial, true).put(64, 2, dir).put(66, 6, LIVE[face]).bytes
+
+ /** Gen4 move history: [moves] are (face, dir) for serials startSerial, startSerial-1, ... */
+ fun gen4History(startSerial: Int, moves: List<Pair<Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 8, 0xD1).put(8, 8, moves.size / 2 + 1).put(16, 8, startSerial)
+ moves.forEachIndexed { i, (face, dir) -> w.put(24 + 4 * i, 3, HISTORY[face]).put(27 + 4 * i, 1, dir) }
+ return w.bytes
+ }
+}
+
+class GanProtocolTest {
+ private var now = 1_000L
+ private val clock = { now }
+
+ @Test fun bitReaderRoundTripsAllWidths() {
+ val w = GanBitWriter(12).put(3, 5, 0b10110).put(8, 16, 0xBEEF).put(27, 16, 0x1234, true).put(43, 32, 0xDEADBEEFL, true)
+ val r = GanBitReader(w.bytes)
+ assertEquals(0b10110, r.bits(3, 5))
+ assertEquals(0xBEEF, r.bits(8, 16))
+ assertEquals(0x1234, r.bits(27, 16, true))
+ assertEquals(0xDEADBEEFL, r.u32(43, true))
+ assertEquals(0, r.bits(200, 8)) // past the end reads as zero
+ }
+
+ @Test fun kociembaFacelets() {
+ assertEquals(GanFacelets.SOLVED, GanFacelets.toKociemba(SOLVED_STATE))
+ assertEquals(F_R_FACELETS, GanFacelets.toKociemba(F_R_STATE))
+ val completed = GanFacelets.complete(F_R_STATE.cp.take(7), F_R_STATE.co.take(7), F_R_STATE.ep.take(11), F_R_STATE.eo.take(11))
+ assertEquals(F_R_STATE, completed)
+ }
+
+ @Test fun gen2Commands() {
+ val d = GanGen2ProtocolDriver(clock)
+ assertEquals(20, d.createCommandMessage(GanCommand.REQUEST_FACELETS).size)
+ assertEquals(0x04, d.createCommandMessage(GanCommand.REQUEST_FACELETS)[0].toInt())
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_HARDWARE)[0].toInt())
+ assertEquals(0x09, d.createCommandMessage(GanCommand.REQUEST_BATTERY)[0].toInt())
+ assertContentEquals(
+ byteArrayOf(0x0A, 0x05, 0x39, 0x77, 0, 0, 0x01, 0x23, 0x45, 0x67, 0x89.toByte(), 0xAB.toByte(), 0, 0, 0, 0, 0, 0, 0, 0),
+ d.createCommandMessage(GanCommand.REQUEST_RESET),
+ )
+ }
+
+ @Test fun gen2FaceletsMovesBatteryHardwareGyro() = runTest {
+ val d = GanGen2ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Moves before the first facelets event are ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen2Moves(5, listOf(Triple(1, 0, 100)))))
+
+ val f = d.handleStateEvent(c, Packets.gen2Facelets(10, F_R_STATE)).single() as Facelets
+ assertEquals(F_R_FACELETS, f.facelets)
+ assertEquals(10, f.serial)
+ assertEquals(F_R_STATE, f.state)
+
+ now = 2_000
+ // Serial 11: one new move R (face 1, CW), 120 ms after the previous.
+ val m1 = d.handleStateEvent(c, Packets.gen2Moves(11, listOf(Triple(1, 0, 120)))).single() as Move
+ assertEquals(Move("R", 120, 2_000, 11, recovered = false), m1)
+
+ // Serial 13: two moves; the older (U', serial 12) was missed live and comes with the newer (F, serial 13).
+ now = 2_500
+ val ms = d.handleStateEvent(c, Packets.gen2Moves(13, listOf(Triple(2, 0, 200), Triple(0, 1, 150))))
+ assertEquals(listOf(Move("U'", 270, 2_500, 12, true), Move("F", 470, 2_500, 13, false)), ms)
+
+ val battery = GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 87).bytes
+ assertEquals(Battery(87), d.handleStateEvent(c, battery).single())
+ assertEquals(Battery(100), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 140).bytes).single())
+
+ val hw = GanBitWriter(20).put(0, 4, 0x5).put(8, 8, 1).put(16, 8, 2).put(24, 8, 3).put(32, 8, 4).put(104, 1, 1)
+ "GAN12ui!".forEachIndexed { i, ch -> hw.put(40 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GAN12ui!", "1.2", "3.4", null, true), d.handleStateEvent(c, hw.bytes).single())
+
+ // Gyro: w = +1, x = -0.5 (sign bit + magnitude), velocity x = -3.
+ val g = GanBitWriter(20).put(0, 4, 0x1).put(4, 16, 0x7FFF).put(20, 16, 0x8000 or 0x3FFF).put(68, 4, 0b1011)
+ val gyro = d.handleStateEvent(c, g.bytes).single() as Gyro
+ assertEquals(1.0, gyro.quaternion.w)
+ assertTrue(abs(gyro.quaternion.x + 0.5) < 1e-3)
+ assertEquals(-3, gyro.velocity!!.x)
+
+ assertEquals(emptyList(), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0xD).bytes))
+ assertEquals(1, c.disconnects)
+ }
+
+ @Test fun gen3Commands() {
+ val d = GanGen3ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0x68, 0x04) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertContentEquals(byteArrayOf(0x68, 0x07) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_RESET)[2].toInt())
+ }
+
+ @Test fun gen3FaceletsMovesBatteryHardware() = runTest {
+ val d = GanGen3ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(1, 0, 0, 10)))
+ assertEquals(Facelets(GanFacelets.SOLVED, 7, SOLVED_STATE), d.handleStateEvent(c, Packets.gen3Facelets(7, SOLVED_STATE)).single())
+ assertEquals(Move("L'", 123_456, 1_000, 8), d.handleStateEvent(c, Packets.gen3Move(8, 4, 1, 123_456)).single())
+ assertEquals(Move("B", 0xFFFFFFF0L, 1_000, 9), d.handleStateEvent(c, Packets.gen3Move(9, 5, 0, 0xFFFFFFF0L)).single())
+ // Wrong magic is ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(10, 0, 0, 1).also { it[0] = 0x54 }))
+
+ val battery = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x10).put(16, 8, 1).put(24, 8, 64).bytes
+ assertEquals(Battery(64), d.handleStateEvent(c, battery).single())
+
+ val hw = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x07).put(16, 8, 9).put(72, 4, 2).put(76, 4, 5).put(80, 4, 1).put(84, 4, 0)
+ "GANic".forEachIndexed { i, ch -> hw.put(32 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GANic", "1.0", "2.5", null, false), d.handleStateEvent(c, hw.bytes).single())
+ }
+
+ @Test fun gen4Commands() {
+ val d = GanGen4ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xED.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xEF.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertContentEquals(byteArrayOf(0xDF.toByte(), 0x03) + ByteArray(18), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertEquals(0xD2.toByte(), d.createCommandMessage(GanCommand.REQUEST_RESET)[0])
+ }
+
+ @Test fun gen4MovesRecoverMissedMovesFromHistory() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(F_R_FACELETS, (d.handleStateEvent(c, Packets.gen4Facelets(10, F_R_STATE)).single() as Facelets).facelets)
+ assertEquals(listOf(Move("D", 5000, 1_000, 11)), d.handleStateEvent(c, Packets.gen4Move(11, 3, 0, 5000)))
+
+ // Serial 14 arrives but 12 and 13 were lost: it is held back and history is requested.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen4Move(14, 1, 1, 5600)))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 13, 0, 4, 0) + ByteArray(14), c.sent.single())
+
+ // History: serials 13 (F'), 12 (U), 11 (D), 10 (whatever) newest first.
+ now = 1_050
+ val recovered = d.handleStateEvent(c, Packets.gen4History(13, listOf(2 to 1, 0 to 0, 3 to 0, 5 to 0)))
+ assertEquals(
+ listOf(Move("U", null, 1_050, 12, true), Move("F'", null, 1_050, 13, true), Move("R'", 5600, 1_000, 14)),
+ recovered,
+ )
+ }
+
+ @Test fun gen4FaceletsTriggerHistoryWhenIdle() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ d.handleStateEvent(c, Packets.gen4Facelets(20, SOLVED_STATE))
+ d.handleStateEvent(c, Packets.gen4Move(21, 0, 0, 100))
+ // Periodic facelets says serial 23 more than 500 ms after the last move: 22 and 23 were missed.
+ now += 600
+ d.handleStateEvent(c, Packets.gen4Facelets(23, SOLVED_STATE))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 23, 0, 4, 0) + ByteArray(14), c.sent.single())
+ val got = d.handleStateEvent(c, Packets.gen4History(23, listOf(1 to 0, 2 to 0, 0 to 0, 0 to 0)))
+ assertEquals(listOf("F", "R"), got.map { (it as Move).move })
+ assertEquals(listOf(22, 23), got.map { (it as Move).serial })
+ }
+
+ @Test fun gen4BatteryHardwareGyro() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Battery level sits right after the payload: byte 1 + dataLength.
+ val battery = GanBitWriter(20).put(0, 8, 0xEF).put(8, 8, 3).put(32, 8, 55).bytes
+ assertEquals(Battery(55), d.handleStateEvent(c, battery).single())
+
+ d.createCommandMessage(GanCommand.REQUEST_HARDWARE)
+ val date = GanBitWriter(20).put(0, 8, 0xFA).put(8, 8, 5).put(24, 16, 2023, true).put(40, 8, 7).put(48, 8, 9).bytes
+ val nameW = GanBitWriter(20).put(0, 8, 0xFC).put(8, 8, 9)
+ "GAN12uiM".forEachIndexed { i, ch -> nameW.put(24 + 8 * i, 8, ch.code) }
+ val sw = GanBitWriter(20).put(0, 8, 0xFD).put(8, 8, 2).put(24, 4, 1).put(28, 4, 6).bytes
+ val hw = GanBitWriter(20).put(0, 8, 0xFE).put(8, 8, 2).put(24, 4, 1).put(28, 4, 1).bytes
+ assertEquals(emptyList(), d.handleStateEvent(c, date))
+ assertEquals(emptyList(), d.handleStateEvent(c, nameW.bytes))
+ assertEquals(emptyList(), d.handleStateEvent(c, sw))
+ assertEquals(Hardware("GAN12uiM", "1.1", "1.6", "2023-07-09", true), d.handleStateEvent(c, hw).single())
+
+ val g = GanBitWriter(20).put(0, 8, 0xEC).put(8, 8, 0x0A).put(16, 16, 0x7FFF).put(48, 16, 0x8000 or 0x7FFF).put(88, 4, 0b0010).bytes
+ val gyro = d.handleStateEvent(c, g).single() as Gyro
+ assertEquals(Quaternion(0.0, -1.0, 0.0, 1.0), gyro.quaternion)
+ assertEquals(AngularVelocity(0, 0, 2), gyro.velocity)
+ }
+
+ @Test fun timestampLinearFit() {
+ // Cube clock runs 10% slow relative to the host.
+ val fit = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 110L, 220L), fit.map { it.cubeTimestampMs })
+ assertEquals(-9.091, GanTimestampFit.skewPercent(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200))))
+
+ // A recovered move without timestamps is interpolated from its neighbours (next - 50 ms).
+ val gap = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", null, null), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 165L, 220L), gap.map { it.cubeTimestampMs })
+ val lastMissing = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1100, 100), TimedMove("R'", null, null)))
+ assertEquals(listOf(0L, 100L, 150L), lastMissing.map { it.cubeTimestampMs })
+ assertEquals(emptyList(), GanTimestampFit.linearFit(emptyList()))
+ }
+
+ @Test fun macAndScanHelpers() {
+ assertContentEquals(byteArrayOf(0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ assertContentEquals(GanDefinitions.saltFromMac("6f-f0-12-34-56-78"), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ // Manufacturer data (company 0x1201): 3 bytes of something, then the MAC reversed.
+ val mfr = mapOf(0x1201 to byteArrayOf(1, 2, 3, 0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F, 9, 9))
+ assertEquals("6F:F0:12:34:56:78", GanDefinitions.macFromManufacturerData(mfr))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("6F:F0:12:34:56:78", null, -60, mfr)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "GAN12uiM_1A2B", -60)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", null, -60, serviceUuids = listOf(GanDefinitions.GEN3_SERVICE))))
+ assertTrue(!GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "Headphones", -60, mfr)))
+ assertEquals(GanGeneration.GEN4, GanGeneration.fromServiceUuids(listOf("0000180a-0000-1000-8000-00805f9b34fb", GanDefinitions.GEN4_SERVICE.uppercase())))
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
new file mode 100644
index 0000000..bbccb57
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
@@ -0,0 +1,6 @@
+package coffee.liz.rookery
+
+actual fun epochMillis(): Long = System.currentTimeMillis()
+actual fun monoMillis(): Long = System.nanoTime() / 1_000_000
+actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+actual fun <T> locked(lock: Any, block: () -> T): T = synchronized(lock, block)
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
new file mode 100644
index 0000000..3b70b67
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
@@ -0,0 +1,21 @@
+package coffee.liz.rookery.audio
+
+import javax.sound.sampled.AudioFormat
+import javax.sound.sampled.AudioSystem
+
+/** Plays [Chime] PCM on a daemon thread. Silently does nothing if there's no audio device. */
+object ChimePlayer {
+ fun play(notes: List<Chime.Note>) {
+ val pcm = Chime.render(notes)
+ Thread {
+ runCatching {
+ val format = AudioFormat(Chime.SAMPLE_RATE.toFloat(), 16, 1, true, false)
+ val bytes = ByteArray(pcm.size * 2)
+ for (i in pcm.indices) { bytes[2 * i] = pcm[i].toByte(); bytes[2 * i + 1] = (pcm[i].toInt() shr 8).toByte() }
+ AudioSystem.getSourceDataLine(format).use { line ->
+ line.open(format); line.start(); line.write(bytes, 0, bytes.size); line.drain()
+ }
+ }
+ }.apply { isDaemon = true; name = "chime" }.start()
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
new file mode 100644
index 0000000..db2ae70
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
@@ -0,0 +1,287 @@
+@file:Suppress("FunctionName")
+
+package coffee.liz.rookery.bt
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.channels.awaitClose
+import org.freedesktop.dbus.DBusPath
+import org.freedesktop.dbus.annotations.DBusInterfaceName
+import org.freedesktop.dbus.connections.impl.DBusConnection
+import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
+import org.freedesktop.dbus.interfaces.DBusInterface
+import org.freedesktop.dbus.interfaces.ObjectManager
+import org.freedesktop.dbus.interfaces.Properties
+import org.freedesktop.dbus.types.Variant
+import java.util.concurrent.CopyOnWriteArrayList
+
+@DBusInterfaceName("org.bluez.Adapter1")
+@JvmSuppressWildcards
+internal interface BluezAdapter1 : DBusInterface {
+ fun StartDiscovery()
+ fun StopDiscovery()
+ fun SetDiscoveryFilter(filter: Map<String, Variant<*>>)
+}
+
+@DBusInterfaceName("org.bluez.Device1")
+internal interface BluezDevice1 : DBusInterface {
+ fun Connect()
+ fun Disconnect()
+}
+
+@DBusInterfaceName("org.bluez.GattCharacteristic1")
+@JvmSuppressWildcards
+internal interface BluezGattCharacteristic1 : DBusInterface {
+ fun ReadValue(options: Map<String, Variant<*>>): ByteArray
+ fun WriteValue(value: ByteArray, options: Map<String, Variant<*>>)
+ fun StartNotify()
+ fun StopNotify()
+}
+
+/**
+ * [BleTransport] over BlueZ's D-Bus API (Linux). Only discovers, connects and disconnects:
+ * never pairs, trusts or changes adapter settings. Close it to release the D-Bus connection.
+ */
+class BlueZTransport(private val adapterName: String? = null) : BleTransport, AutoCloseable {
+ private val bus: DBusConnection by lazy {
+ DBusConnectionBuilder.forSystemBus().withShared(false).build().also { c ->
+ c.addSigHandler(Properties.PropertiesChanged::class.java) { s ->
+ // dbus-java can't match on a well-known sender name, so filter by BlueZ's object paths instead.
+ if (s.path.startsWith(BLUEZ_ROOT)) listeners.forEach { it(s.path, s.interfaceName, s.propertiesChanged) }
+ }
+ c.addSigHandler(ObjectManager.InterfacesAdded::class.java) { s ->
+ val path = s.signalSource.path
+ if (path.startsWith(BLUEZ_ROOT)) s.interfaces.forEach { (iface, props) -> listeners.forEach { it(path, iface, props) } }
+ }
+ }
+ }
+
+ /** (objectPath, interface, changedProperties) */
+ private val listeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+
+ private fun managedObjects(): Map<String, Map<String, Map<String, Variant<*>>>> =
+ bus.getRemoteObject(BLUEZ, "/", ObjectManager::class.java).GetManagedObjects().mapKeys { it.key.path }
+
+ private fun adapterPath(objects: Map<String, Map<String, Map<String, Variant<*>>>> = managedObjects()): String {
+ val adapters = objects.filterValues { ADAPTER in it }.keys.sorted()
+ if (adapterName != null) return adapters.firstOrNull { it.endsWith("/$adapterName") } ?: error("No Bluetooth adapter $adapterName")
+ return adapters.firstOrNull { objects[it]!![ADAPTER]!!["Powered"]?.value == true } ?: adapters.firstOrNull()
+ ?: error("No Bluetooth adapter found (is bluetoothd running?)")
+ }
+
+ private fun adapter(path: String) = bus.getRemoteObject(BLUEZ, path, BluezAdapter1::class.java)
+
+ private fun devicePath(adapter: String, address: String) = "$adapter/dev_" + address.uppercase().replace(':', '_')
+
+ override fun scan(): Flow<BleAdvertisement> = callbackFlow {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val devices = HashMap<String, MutableMap<String, Any?>>()
+ fun update(path: String, props: Map<String, Variant<*>>, emitIfNoRssi: Boolean) {
+ if (!path.startsWith("$adapterPath/dev_") || path.count { it == '/' } != adapterPath.count { it == '/' } + 1) return
+ val merged = synchronized(devices) {
+ devices.getOrPut(path) { HashMap() }.also { m -> props.forEach { (k, v) -> m[k] = v.value } }.toMap()
+ }
+ if (!emitIfNoRssi && merged["RSSI"] == null) return
+ toAdvertisement(merged)?.let { trySend(it) }
+ }
+ val listener: (String, String, Map<String, Variant<*>>) -> Unit = { path, iface, props -> if (iface == DEVICE) update(path, props, true) }
+ listeners += listener
+ // Devices BlueZ already knows about are only reported if they carry a recent RSSI.
+ objects.forEach { (path, ifaces) -> ifaces[DEVICE]?.let { update(path, it, false) } }
+ val adapter = adapter(adapterPath)
+ runCatching { adapter.SetDiscoveryFilter(mapOf("Transport" to Variant("le"), "DuplicateData" to Variant(true))) }
+ try {
+ adapter.StartDiscovery()
+ } catch (e: Exception) {
+ // Another client may already be discovering on this adapter; that still yields results.
+ if (e.message?.contains("InProgress") != true) {
+ listeners -= listener
+ throw e
+ }
+ }
+ awaitClose {
+ listeners -= listener
+ runCatching { adapter.StopDiscovery() }
+ }
+ }.flowOn(Dispatchers.IO)
+
+ override suspend fun connect(address: String): BleConnection = withContext(Dispatchers.IO) {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val path = devicePath(adapterPath, address)
+ if (path !in objects) {
+ // BlueZ only knows devices it has seen: discover until this one shows up.
+ withTimeoutOrNull(20_000) { scan().first { it.address.equals(address, ignoreCase = true) } }
+ ?: error("Device $address not found; is it awake and in range?")
+ }
+ val conn = BlueZConnection(path, address)
+ try {
+ conn.open()
+ } catch (e: Throwable) {
+ conn.close()
+ throw e
+ }
+ conn
+ }
+
+ override fun close() {
+ runCatching { bus.close() }
+ }
+
+ private fun props(path: String, iface: String): Map<String, Any?> =
+ bus.getRemoteObject(BLUEZ, path, Properties::class.java).GetAll(iface).mapValues { it.value.value }
+
+ private inner class BlueZConnection(private val path: String, override val address: String) : BleConnection {
+ private val device = bus.getRemoteObject(BLUEZ, path, BluezDevice1::class.java)
+ private val gone = CompletableDeferred<Unit>()
+ private val notifying = CopyOnWriteArrayList<String>()
+ private val channels = CopyOnWriteArrayList<Channel<ByteArray>>()
+ private val ownListeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+ private var chars: Map<Pair<String, String>, String> = emptyMap()
+ override var name: String? = null
+ private set
+
+ private fun listen(l: (String, String, Map<String, Variant<*>>) -> Unit) {
+ ownListeners += l
+ listeners += l
+ }
+
+ suspend fun open() {
+ listen { p, iface, changed ->
+ if (p == path && iface == DEVICE && changed["Connected"]?.value == false) markGone()
+ }
+ var last: Exception? = null
+ for (attempt in 1..3) {
+ try {
+ device.Connect()
+ last = null
+ break
+ } catch (e: Exception) {
+ // "le-connection-abort-by-local" and friends are common and usually transient.
+ last = e
+ delay(500)
+ }
+ }
+ last?.let { throw IllegalStateException("Connect to $address failed: ${it.message}", it) }
+ val resolved = withTimeoutOrNull(15_000) {
+ while (props(path, DEVICE)["ServicesResolved"] != true) delay(100)
+ true
+ }
+ check(resolved == true) { "GATT services of $address were not resolved in time" }
+ val p = props(path, DEVICE)
+ name = p["Name"] as? String
+ }
+
+ override suspend fun discoverServices(): List<BleService> = withContext(Dispatchers.IO) {
+ val objects = managedObjects().filterKeys { it.startsWith("$path/") }
+ val services = objects.mapNotNull { (p, ifaces) -> ifaces[GATT_SERVICE]?.let { p to (it["UUID"]!!.value as String).lowercase() } }.toMap()
+ val found = HashMap<Pair<String, String>, String>()
+ val byService = HashMap<String, MutableList<BleCharacteristic>>()
+ objects.forEach { (p, ifaces) ->
+ val c = ifaces[GATT_CHAR] ?: return@forEach
+ val servicePath = (c["Service"]?.value as? DBusPath)?.path ?: p.substringBeforeLast('/')
+ val serviceUuid = services[servicePath] ?: return@forEach
+ val uuid = (c["UUID"]!!.value as String).lowercase()
+ found[serviceUuid to uuid] = p
+ byService.getOrPut(servicePath) { mutableListOf() } += BleCharacteristic(uuid, stringList(c["Flags"]?.value).toSet())
+ }
+ chars = found
+ services.map { (p, uuid) -> BleService(uuid, byService[p].orEmpty()) }
+ }
+
+ private fun charPath(service: String, characteristic: String): String =
+ chars[service.lowercase() to characteristic.lowercase()] ?: error("Characteristic $characteristic of service $service not found")
+
+ private fun char(service: String, characteristic: String) =
+ bus.getRemoteObject(BLUEZ, charPath(service, characteristic), BluezGattCharacteristic1::class.java)
+
+ override suspend fun read(service: String, characteristic: String): ByteArray = withContext(Dispatchers.IO) {
+ char(service, characteristic).ReadValue(emptyMap())
+ }
+
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) = withContext(Dispatchers.IO) {
+ char(service, characteristic).WriteValue(value, emptyMap())
+ }
+
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = withContext(Dispatchers.IO) {
+ val cp = charPath(service, characteristic)
+ val ch = Channel<ByteArray>(Channel.UNLIMITED)
+ channels += ch
+ listen { p, iface, changed ->
+ if (p == cp && iface == GATT_CHAR) changed["Value"]?.let { v -> toBytes(v.value)?.let { ch.trySend(it) } }
+ }
+ bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StartNotify()
+ notifying += cp
+ ch.receiveAsFlow()
+ }
+
+ override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) }
+
+ private fun markGone() {
+ channels.forEach { it.close() }
+ gone.complete(Unit)
+ }
+
+ fun close() {
+ ownListeners.forEach { listeners -= it }
+ markGone()
+ }
+
+ override suspend fun disconnect() = withContext(NonCancellable + Dispatchers.IO) {
+ notifying.forEach { cp -> runCatching { bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StopNotify() } }
+ notifying.clear()
+ runCatching { device.Disconnect() }
+ close()
+ }
+ }
+
+ private companion object {
+ const val BLUEZ = "org.bluez"
+ const val BLUEZ_ROOT = "/org/bluez/"
+ const val ADAPTER = "org.bluez.Adapter1"
+ const val DEVICE = "org.bluez.Device1"
+ const val GATT_SERVICE = "org.bluez.GattService1"
+ const val GATT_CHAR = "org.bluez.GattCharacteristic1"
+
+ fun toBytes(v: Any?): ByteArray? = when (v) {
+ is ByteArray -> v
+ is List<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ is Array<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ else -> null
+ }
+
+ fun stringList(v: Any?): List<String> = when (v) {
+ is List<*> -> v.map { it.toString() }
+ is Array<*> -> v.map { it.toString() }
+ else -> emptyList()
+ }
+
+ fun toAdvertisement(p: Map<String, Any?>): BleAdvertisement? {
+ val address = p["Address"] as? String ?: return null
+ val mfr = (p["ManufacturerData"] as? Map<*, *>)?.mapNotNull { (k, v) ->
+ val id = (k as? Number)?.toInt() ?: return@mapNotNull null
+ val bytes = toBytes((v as? Variant<*>)?.value ?: v) ?: return@mapNotNull null
+ id to bytes
+ }?.toMap().orEmpty()
+ return BleAdvertisement(
+ address = address,
+ name = p["Name"] as? String,
+ rssi = (p["RSSI"] as? Number)?.toInt(),
+ manufacturerData = mfr,
+ serviceUuids = stringList(p["UUIDs"]).map { it.lowercase() },
+ )
+ }
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
new file mode 100644
index 0000000..b96748c
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
@@ -0,0 +1,17 @@
+package coffee.liz.rookery.bt
+
+import javax.crypto.Cipher
+import javax.crypto.spec.SecretKeySpec
+
+/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */
+object JvmAes128 : Aes128 {
+ private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray {
+ require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" }
+ val c = Cipher.getInstance("AES/ECB/NoPadding")
+ c.init(mode, SecretKeySpec(key, "AES"))
+ return c.doFinal(block)
+ }
+
+ override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block)
+ override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block)
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
new file mode 100644
index 0000000..e0e7b31
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
@@ -0,0 +1,50 @@
+package coffee.liz.rookery.scramble
+
+import coffee.liz.rookery.model.Event
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+/**
+ * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event
+ * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build).
+ */
+class TnoodleScrambler : Scrambler {
+ private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } }
+ private val ahead = ConcurrentHashMap<Event, Future<String>>()
+
+ private fun registry(e: Event): PuzzleRegistry = when (e) {
+ Event.E333, Event.E333OH -> PuzzleRegistry.THREE
+ Event.E222 -> PuzzleRegistry.TWO
+ Event.E444 -> PuzzleRegistry.FOUR
+ Event.E555 -> PuzzleRegistry.FIVE
+ Event.E666 -> PuzzleRegistry.SIX
+ Event.E777 -> PuzzleRegistry.SEVEN
+ Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI
+ Event.E444BF -> PuzzleRegistry.FOUR_NI
+ Event.E555BF -> PuzzleRegistry.FIVE_NI
+ Event.E333FM -> PuzzleRegistry.THREE_FM
+ Event.CLOCK -> PuzzleRegistry.CLOCK
+ Event.MINX -> PuzzleRegistry.MEGA
+ Event.PYRAM -> PuzzleRegistry.PYRA
+ Event.SKEWB -> PuzzleRegistry.SKEWB
+ Event.SQ1 -> PuzzleRegistry.SQ1
+ Event.FTO -> PuzzleRegistry.FTO
+ }
+
+ private fun submit(e: Event): Future<String> = pool.submit<String> { registry(e).scrambler.generateScramble() }
+
+ override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) }
+
+ override fun next(event: Event): String {
+ val f = ahead.remove(event) ?: submit(event)
+ val s = f.get()
+ ahead[event] = submit(event)
+ return s
+ }
+
+ override fun previewSvg(event: Event, scramble: String): String? = runCatching {
+ registry(event).scrambler.drawScramble(scramble, null).toString()
+ }.getOrNull()
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
new file mode 100644
index 0000000..64064b5
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
@@ -0,0 +1,96 @@
+package coffee.liz.rookery.store
+
+import java.io.RandomAccessFile
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.security.MessageDigest
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.name
+import kotlin.io.path.readBytes
+import kotlin.io.path.writeBytes
+
+/**
+ * Op log in a plain folder, safe under any file-syncing tool:
+ *
+ * ```
+ * <root>/devices/<nodeId>/ops-000001.jsonl only <nodeId> ever writes here
+ * <root>/blobs/<sha256>.<ext> write-once, content addressed
+ * ```
+ *
+ * Readers remember a byte offset per file and only consume complete lines, so a half-synced
+ * file is simply read again later.
+ */
+class FolderLogStorage(private val root: Path, private val nodeId: String, private val segmentBytes: Long = 8L shl 20) : LogStorage {
+ private val devices = root.resolve("devices")
+ private val ownDir = devices.resolve(nodeId).also { it.createDirectories() }
+ private val offsets = HashMap<Path, Long>()
+
+ private fun currentSegment(): Path {
+ val segs = ownDir.listDirectoryEntries("ops-*.jsonl").sorted()
+ val last = segs.lastOrNull()
+ if (last != null && Files.size(last) < segmentBytes) return last
+ val next = (last?.name?.removePrefix("ops-")?.removeSuffix(".jsonl")?.toIntOrNull() ?: 0) + 1
+ return ownDir.resolve("ops-" + next.toString().padStart(6, '0') + ".jsonl")
+ }
+
+ override fun append(lines: List<String>) {
+ if (lines.isEmpty()) return
+ val bytes = lines.joinToString("\n", postfix = "\n").toByteArray()
+ val seg = currentSegment()
+ Files.newByteChannel(seg, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE).use { ch ->
+ val buf = java.nio.ByteBuffer.wrap(bytes)
+ while (buf.hasRemaining()) ch.write(buf)
+ (ch as? java.nio.channels.FileChannel)?.force(false)
+ }
+ // Our own writes are already applied by the Store; skip them on the next read.
+ offsets[seg] = (offsets[seg] ?: 0) + bytes.size
+ }
+
+ override fun readNew(): List<String> {
+ if (!devices.exists()) return emptyList()
+ val out = mutableListOf<String>()
+ for (dev in devices.listDirectoryEntries().filter { it.isDirectory() }.sorted()) {
+ for (seg in dev.listDirectoryEntries("ops-*.jsonl").sorted()) {
+ val from = offsets[seg] ?: 0
+ val size = Files.size(seg)
+ if (size <= from) continue
+ RandomAccessFile(seg.toFile(), "r").use { f ->
+ f.seek(from)
+ val buf = ByteArray((size - from).toInt())
+ f.readFully(buf)
+ val end = buf.lastIndexOf('\n'.code.toByte())
+ if (end < 0) return@use // torn line still syncing
+ String(buf, 0, end, Charsets.UTF_8).split('\n').filterTo(out) { it.isNotBlank() }
+ offsets[seg] = from + end + 1
+ }
+ }
+ }
+ return out
+ }
+}
+
+class FolderBlobStore(root: Path) : BlobStore {
+ private val dir = root.resolve("blobs").also { it.createDirectories() }
+
+ override fun put(bytes: ByteArray, ext: String): String {
+ val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+ val name = "$hash.${ext.lowercase().filter(Char::isLetterOrDigit).ifEmpty { "bin" }}"
+ val path = dir.resolve(name)
+ if (!path.exists()) {
+ val tmp = dir.resolve(".$name.tmp")
+ tmp.writeBytes(bytes)
+ Files.move(tmp, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
+ }
+ return name
+ }
+
+ override fun get(name: String): ByteArray? {
+ if (name.contains('/') || name.contains("..")) return null
+ val p = dir.resolve(name)
+ return if (p.exists()) p.readBytes() else null
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt
new file mode 100644
index 0000000..ef76184
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt
@@ -0,0 +1,55 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import java.nio.file.Files
+import kotlin.io.path.appendText
+import kotlin.io.path.listDirectoryEntries
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class JvmTest {
+ @Test fun folderSyncBetweenDevicesToleratesTornLines() {
+ val root = Files.createTempDirectory("rookery-sync")
+ fun open(node: String) = Repo(Store(node, FolderLogStorage(root, node), FolderBlobStore(root)) { System.currentTimeMillis() })
+ val a = open("phone")
+ a.addSolve(Solve("s1", "x", Event.E333, 9000, Penalty.NONE, "R", "", 1))
+
+ val b = open("laptop") // fresh device reads everything on startup
+ assertEquals(listOf("s1"), b.solves().map { it.id })
+
+ // simulate Syncthing delivering half of a line from phone
+ val seg = root.resolve("devices/phone").listDirectoryEntries().single()
+ val line = """{"t":"9999999999999-0000-phone","c":"solve","id":"s1","f":"pen","v":"dnf"}"""
+ seg.appendText(line.substring(0, 20))
+ b.store.sync()
+ assertEquals(Penalty.NONE, b.solves().single().penalty)
+ seg.appendText(line.substring(20) + "\n")
+ b.store.sync()
+ assertEquals(Penalty.DNF, b.solves().single().penalty)
+
+ // restart: state rebuilds from disk
+ assertEquals(Penalty.DNF, open("laptop").solves().single().penalty)
+ val blob = a.store.blobs.put(byteArrayOf(1, 2, 3), "jpg")
+ assertEquals(3, open("laptop").store.blobs.get(blob)?.size)
+ }
+
+ /** Runs under the app's 256m heap cap (see core/build.gradle.kts), so this also guards memory. */
+ @Test fun tnoodleScramblesAndDraws() {
+ val s = TnoodleScrambler()
+ for (e in Event.entries) {
+ val scr = s.next(e)
+ assertTrue(scr.isNotBlank(), "$e")
+ val svg = assertNotNull(s.previewSvg(e, scr), "$e svg")
+ assertTrue(svg.contains("<svg"), "$e svg: ${svg.take(80)}")
+ }
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt
new file mode 100644
index 0000000..dd5e9a8
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt
@@ -0,0 +1,103 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.puzzle.TwistyPuzzle
+import coffee.liz.rookery.puzzle.V3
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import kotlin.math.atan2
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Our 3D models must agree sticker-for-sticker with tnoodle's official 2D drawings. Each model
+ * sticker is mapped onto tnoodle's net through its face (an affine map between the face's 3D and
+ * 2D corners); the colour found there must match ours.
+ */
+class TnoodleAgreementTest {
+ private data class P2(val x: Double, val y: Double)
+ private data class NetSticker(val c: P2, val color: String)
+
+ /** Sticker centroids and fills from a tnoodle SVG, applying each path's transform. */
+ private fun parse(svg: String): List<NetSticker> = Regex("""<path [^>]*>""").findAll(svg).mapNotNull { m ->
+ fun attr(n: String) = Regex("""\b$n="([^"]*)"""").find(m.value)?.groupValues?.get(1)
+ val d = attr("d") ?: return@mapNotNull null
+ val fill = attr("fill") ?: return@mapNotNull null
+ val t = attr("transform")?.let { Regex("""matrix\(([^)]*)\)""").find(it)?.groupValues?.get(1)?.split(',')?.map { v -> v.trim().toDouble() } }
+ ?: listOf(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
+ val pts = Regex("""[ML] ([-\d.]+) ([-\d.]+)""").findAll(d).map {
+ val x = it.groupValues[1].toDouble(); val y = it.groupValues[2].toDouble()
+ P2(t[0] * x + t[2] * y + t[4], t[1] * x + t[3] * y + t[5])
+ }.toList().distinct()
+ NetSticker(P2(pts.sumOf { it.x } / pts.size, pts.sumOf { it.y } / pts.size), fill)
+ }.toList()
+
+ private fun d2(a: P2, b: P2) = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
+
+ /**
+ * Net face corners, in order around the face: the face's corner stickers pushed outwards from its
+ * centre (corner-sticker centroids sit 2/3 of the way to the corner for both puzzles here).
+ */
+ private fun netCorners(face: List<NetSticker>, n: Int): List<P2> {
+ val cx = face.sumOf { it.c.x } / face.size; val cy = face.sumOf { it.c.y } / face.size
+ return face.sortedByDescending { d2(it.c, P2(cx, cy)) }.take(n)
+ .sortedBy { atan2(it.c.y - cy, it.c.x - cx) }
+ .map { P2(cx + (it.c.x - cx) * 1.5, cy + (it.c.y - cy) * 1.5) }
+ }
+
+ /** Map a point on a 3D face to the net via the face's first three corners (affine). */
+ private fun toNet(p: V3, c3: List<V3>, c2: List<P2>): P2 {
+ val e1 = c3[1] - c3[0]; val e2 = c3[c3.size - 1] - c3[0]; val v = p - c3[0]
+ val d11 = e1 dot e1; val d12 = e1 dot e2; val d22 = e2 dot e2
+ val den = d11 * d22 - d12 * d12
+ val s = ((v dot e1) * d22 - (v dot e2) * d12) / den
+ val t = ((v dot e2) * d11 - (v dot e1) * d12) / den
+ val a = c2[0]; val b = c2[1]; val d = c2[c2.size - 1]
+ return P2(a.x + s * (b.x - a.x) + t * (d.x - a.x), a.y + s * (b.y - a.y) + t * (d.y - a.y))
+ }
+
+ private fun checkAgreement(reg: PuzzleRegistry, model: TwistyPuzzle, faceCorners: List<List<V3>>, faceColors: List<String>) {
+ val scrambler = reg.scrambler
+ val solvedNet = parse(scrambler.drawScramble("", null).toString())
+ val n = faceCorners[0].size
+ val colorIndex = faceColors.withIndex().associate { (i, c) -> c to i }
+
+ fun mismatches(scr: String, mapping: Map<Int, List<P2>>, only: Int? = null): Int {
+ val net = parse(scrambler.drawScramble(scr, null).toString())
+ val st = model.solved().apply(scr)
+ return model.stickers.indices.count { i ->
+ val f = model.stickerFace[i]
+ if (only != null && f != only) return@count false
+ val p = toNet(model.stickers[i].centroid, faceCorners[f], mapping.getValue(f))
+ colorIndex[net.minBy { d2(it.c, p) }.color] != st.colors[i]
+ }
+ }
+
+ // Which net corner is which 3D corner: pick the rotation/reflection that fits reference scrambles
+ // (several, since one scramble can leave a face symmetric enough to tie two alignments)…
+ val refs = List(4) { scrambler.generateScramble() }
+ val mapping = HashMap<Int, List<P2>>()
+ for ((color, face) in solvedNet.groupBy { it.color }) {
+ val f = colorIndex.getValue(color)
+ val c = netCorners(face, n)
+ val options = (0 until n).flatMap { r -> val rot = c.drop(r) + c.take(r); listOf(rot, rot.reversed()) }
+ mapping[f] = options.minBy { opt -> refs.sumOf { mismatches(it, mapping + (f to opt), f) } }
+ }
+ // …then that fixed alignment must hold for fresh scrambles.
+ repeat(15) {
+ val scr = scrambler.generateScramble()
+ assertEquals(0, mismatches(scr, mapping), "${model.name} disagrees with tnoodle on $scr")
+ }
+ }
+
+ @Test fun pyraminxMatchesTnoodle() {
+ val m = Puzzles.pyraminx()
+ val v = m.axes.associate { it.name to it.normal }
+ val corners = listOf(listOf("U", "L", "R"), listOf("U", "L", "B"), listOf("U", "R", "B"), listOf("L", "R", "B")).map { f -> f.map(v::getValue) }
+ checkAgreement(PuzzleRegistry.PYRA, m, corners, listOf("#00ff00", "#ff0000", "#0000ff", "#ffff00"))
+ }
+
+ @Test fun skewbMatchesTnoodle() {
+ val corners = Puzzles.cube(1).stickers.map { it.points }
+ checkAgreement(PuzzleRegistry.SKEWB, Puzzles.skewb(), corners, listOf("#ffffff", "#ff0000", "#00ff00", "#ffff00", "#ff8000", "#0000ff"))
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt
new file mode 100644
index 0000000..a8ad88e
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt
@@ -0,0 +1,40 @@
+package coffee.liz.rookery.bt
+
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertFailsWith
+
+@OptIn(ExperimentalStdlibApi::class)
+class GanCryptoTest {
+ // Vectors computed with a Node.js re-implementation of gan-web-bluetooth's GanGen2CubeEncrypter
+ // (aes-js CBC swapped for node:crypto aes-128-cbc, no padding). The MAC is made up; it is chosen so
+ // that key[5] + salt[5] = 0x100, exercising the firmware's `% 0xFF` (255, not 256) salting.
+ private val mac = "6F:F0:12:34:56:78"
+ private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac)
+
+ @Test fun saltedKeyAndIv() {
+ assertContentEquals("7958763a220116072005185442111253".hexToByteArray(), enc.key)
+ assertContentEquals("8959663a127076272095781432120243".hexToByteArray(), enc.iv)
+ }
+
+ @Test fun encryptMatchesReference() {
+ val m20 = "030a11181f262d343b424950575e656c737a8188".hexToByteArray()
+ assertContentEquals("f8b5b4a9ac44104f5d5ce7635f869bf95bc95c02".hexToByteArray(), enc.encrypt(m20))
+ val m16 = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2".hexToByteArray()
+ assertContentEquals("c4f8c0629684911cb703cd9a76f44ffb".hexToByteArray(), enc.encrypt(m16))
+ }
+
+ @Test fun roundTrips() {
+ val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac)
+ for (size in listOf(16, 17, 19, 20, 31, 32)) {
+ val msg = ByteArray(size) { (it * 37 + size).toByte() }
+ assertContentEquals(msg, enc.decrypt(enc.encrypt(msg)))
+ assertContentEquals(msg, moyu.decrypt(moyu.encrypt(msg)))
+ }
+ }
+
+ @Test fun rejectsShortMessages() {
+ assertFailsWith<IllegalArgumentException> { enc.encrypt(ByteArray(15)) }
+ assertFailsWith<IllegalArgumentException> { enc.decrypt(ByteArray(15)) }
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt
new file mode 100644
index 0000000..87c5eb4
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt
@@ -0,0 +1,118 @@
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Facelets
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+private class FakeConnection(override val address: String, override val name: String?, private val services: List<BleService>) : BleConnection {
+ val notifications = Channel<ByteArray>(Channel.UNLIMITED)
+ val writes = mutableListOf<Triple<String, String, ByteArray>>()
+ val subscribed = mutableListOf<Pair<String, String>>()
+ val gone = CompletableDeferred<Unit>()
+ var disconnectCalls = 0
+
+ override suspend fun discoverServices() = services
+ override suspend fun read(service: String, characteristic: String) = ByteArray(0)
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) { writes += Triple(service, characteristic, value) }
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> {
+ subscribed += service to characteristic
+ return notifications.receiveAsFlow()
+ }
+ override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) }
+ override suspend fun disconnect() { disconnectCalls++; gone.complete(Unit) }
+}
+
+private class FakeTransport(val conn: FakeConnection) : BleTransport {
+ override fun scan(): Flow<BleAdvertisement> = emptyFlow()
+ override suspend fun connect(address: String): BleConnection { check(address == conn.address); return conn }
+}
+
+class GanCubeConnectorTest {
+ private val mac = "6F:F0:12:34:56:78"
+ private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac)
+
+ private fun service(g: GanGeneration) = BleService(g.service, listOf(BleCharacteristic(g.command), BleCharacteristic(g.state)))
+ private val deviceInfo = BleService("0000180a-0000-1000-8000-00805f9b34fb", emptyList())
+
+ private fun TestScope.collect(cube: SmartCube): MutableList<SmartCubeEvent> {
+ val got = mutableListOf<SmartCubeEvent>()
+ backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { cube.events.collect { got += it } }
+ return got
+ }
+
+ @Test fun gen3EndToEnd() = runTest {
+ val conn = FakeConnection(mac, "GANicV2_1234", listOf(deviceInfo, service(GanGeneration.GEN3)))
+ val cube = GanCubeConnector(FakeTransport(conn), JvmAes128) { 42L }.connect(backgroundScope, mac)
+ assertEquals(GanGeneration.GEN3, (cube as GanSmartCube).generation)
+ assertEquals(listOf(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_STATE), conn.subscribed)
+ val got = collect(cube)
+
+ cube.requestFacelets()
+ val (svc, chr, value) = conn.writes.single()
+ assertEquals(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_COMMAND, svc to chr)
+ assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), enc.decrypt(value))
+
+ conn.notifications.send(enc.encrypt(Packets.gen3Facelets(3, F_R_STATE)))
+ conn.notifications.send(enc.encrypt(Packets.gen3Move(4, 1, 0, 777)))
+ conn.notifications.send(ByteArray(8)) // too short to decrypt: dropped
+ runCurrent()
+ assertEquals(listOf(Facelets(F_R_FACELETS, 3, F_R_STATE), Move("R", 777, 42, 4)), got)
+
+ conn.gone.complete(Unit) // the cube went away
+ runCurrent()
+ assertEquals(SmartCubeEvent.Disconnected, got.last())
+ }
+
+ @Test fun gen4AndGen2Detection() = runTest {
+ val c4 = FakeConnection(mac, "GAN12uiM_ABCD", listOf(service(GanGeneration.GEN4)))
+ val cube4 = GanCubeConnector(FakeTransport(c4), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube
+ assertEquals(GanGeneration.GEN4, cube4.generation)
+ val got = collect(cube4)
+ c4.notifications.send(enc.encrypt(Packets.gen4Facelets(1, SOLVED_STATE)))
+ runCurrent()
+ assertEquals(GanFacelets.SOLVED, (got.single() as Facelets).facelets)
+ cube4.resetToSolved()
+ assertEquals(0xD2.toByte(), enc.decrypt(c4.writes.single().third)[0])
+ cube4.disconnect()
+ runCurrent()
+ assertEquals(1, c4.disconnectCalls)
+ assertEquals(SmartCubeEvent.Disconnected, got.last())
+
+ val c2 = FakeConnection(mac, "GAN-1234", listOf(service(GanGeneration.GEN2)))
+ val cube2 = GanCubeConnector(FakeTransport(c2), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube
+ assertEquals(GanGeneration.GEN2, cube2.generation)
+ cube2.requestBattery()
+ assertEquals(0x09, enc.decrypt(c2.writes.single().third)[0].toInt())
+ cube2.disconnect()
+ }
+
+ @Test fun moyuAiUsesItsOwnKey() = runTest {
+ val c = FakeConnection(mac, "AiCube_0001", listOf(service(GanGeneration.GEN2)))
+ val cube = GanCubeConnector(FakeTransport(c), JvmAes128) { 0L }.connect(backgroundScope, mac)
+ cube.requestHardware()
+ val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac)
+ assertEquals(0x05, moyu.decrypt(c.writes.single().third)[0].toInt())
+ cube.disconnect()
+ }
+
+ @Test fun unknownDeviceIsRejectedAndDisconnected() = runTest {
+ val c = FakeConnection(mac, "Speaker", listOf(deviceInfo))
+ assertFailsWith<IllegalStateException> { GanCubeConnector(FakeTransport(c), JvmAes128).connect(backgroundScope, mac) }
+ assertTrue(c.disconnectCalls == 1)
+ }
+}
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
new file mode 100644
index 0000000..d143c5f
--- /dev/null
+++ b/desktop/build.gradle.kts
@@ -0,0 +1,30 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.compose)
+}
+
+kotlin { jvmToolchain(21) }
+
+dependencies {
+ implementation(project(":ui"))
+ implementation(compose.desktop.currentOs)
+ implementation(libs.coroutines.swing)
+}
+
+compose.desktop {
+ application {
+ mainClass = "coffee.liz.rookery.desktop.MainKt"
+ jvmArgs += listOf("-Xmx256m", "-XX:+UseSerialGC")
+ nativeDistributions {
+ targetFormats(TargetFormat.AppImage, TargetFormat.Deb)
+ packageName = "rookery"
+ packageVersion = "0.1.0"
+ }
+ }
+}
+
+// `./gradlew :desktop:run --args="--import file.txt"` passes args through; env lets tests point at a scratch home.
+tasks.withType<JavaExec>().configureEach { environment.putAll(System.getenv().filterKeys { it.startsWith("XDG_") || it.startsWith("ROOKERY_") }) }
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
new file mode 100644
index 0000000..b183420
--- /dev/null
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
@@ -0,0 +1,80 @@
+package coffee.liz.rookery.desktop
+
+import coffee.liz.rookery.bt.BleAdvertisement
+import coffee.liz.rookery.bt.BlueZTransport
+import coffee.liz.rookery.bt.GanCubeConnector
+import coffee.liz.rookery.bt.GanSmartCube
+import coffee.liz.rookery.bt.JvmAes128
+import coffee.liz.rookery.bt.SmartCubeEvent
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.system.exitProcess
+
+/**
+ * `rookery --cube scan [seconds] [--all]` lists nearby GAN smart cubes (or every BLE device) for ~10 s.
+ * `rookery --cube monitor <MAC>` connects and streams moves until Ctrl-C.
+ */
+fun cubeCli(args: List<String>) {
+ val usage = "usage: --cube scan [seconds] [--all] | --cube monitor <MAC>"
+ when (args.firstOrNull()) {
+ "scan" -> scan(args.getOrNull(1)?.toLongOrNull() ?: 10, all = "--all" in args)
+ "monitor" -> monitor(args.getOrNull(1) ?: run { System.err.println(usage); exitProcess(2) })
+ else -> { System.err.println(usage); exitProcess(2) }
+ }
+ exitProcess(0)
+}
+
+private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport ->
+ println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...")
+ val seen = LinkedHashMap<String, BleAdvertisement>()
+ runBlocking {
+ withTimeoutOrNull(seconds * 1000) {
+ transport.scan().collect { adv ->
+ if (!all && !GanCubeConnector.isGanCube(adv)) return@collect
+ if (adv.address !in seen) println(" found ${adv.name ?: "?"} ${adv.address} rssi ${adv.rssi ?: "?"}")
+ seen[adv.address] = adv
+ }
+ }
+ }
+ if (seen.isEmpty()) println(if (all) "No BLE devices found." else "No GAN cubes found (turn the cube a little to wake it).")
+ else {
+ println("${seen.size} ${if (all) "device" else "cube"}(s):")
+ seen.values.forEach { println(" ${it.name ?: "?"}\t${it.address}\trssi ${it.rssi ?: "?"}") }
+ }
+}
+
+private fun monitor(mac: String) = BlueZTransport().use { transport ->
+ runBlocking {
+ println("Connecting to $mac...")
+ val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac)
+ println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})")
+ val done = CompletableDeferred<Unit>()
+ Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } })
+ var firstMoveHost: Long? = null
+ val collector = launch(start = CoroutineStart.UNDISPATCHED) {
+ cube.events.collect { e ->
+ when (e) {
+ is SmartCubeEvent.Move -> {
+ val t0 = firstMoveHost ?: e.hostTimestampMs.also { firstMoveHost = it }
+ println("move %-3s host +%6d ms cube %s%s".format(e.move, e.hostTimestampMs - t0, e.cubeTimestampMs?.let { "$it ms" } ?: "-", if (e.recovered) " (recovered)" else ""))
+ }
+ is SmartCubeEvent.Facelets -> println("facelets ${e.facelets}")
+ is SmartCubeEvent.Battery -> println("battery ${e.percent}%")
+ is SmartCubeEvent.Hardware -> println("hardware ${e.name} hw ${e.hardwareVersion} sw ${e.softwareVersion}" + (e.productDate?.let { " made $it" } ?: "") + if (e.gyroSupported) " gyro" else "")
+ is SmartCubeEvent.Gyro -> Unit // too chatty to print
+ SmartCubeEvent.Disconnected -> { println("disconnected"); done.complete(Unit) }
+ }
+ }
+ }
+ cube.requestHardware(); delay(300)
+ cube.requestBattery(); delay(300)
+ cube.requestFacelets()
+ println("Streaming moves; Ctrl-C to quit.")
+ done.await()
+ collector.cancel()
+ }
+}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
new file mode 100644
index 0000000..60d7443
--- /dev/null
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
@@ -0,0 +1,147 @@
+package coffee.liz.rookery.desktop
+
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.audio.ChimePlayer
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import coffee.liz.rookery.ui.AppModel
+import coffee.liz.rookery.ui.Host
+import coffee.liz.rookery.ui.RookeryApp
+import coffee.liz.rookery.ui.handleTimerKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.awt.FileDialog
+import java.awt.Frame
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.util.Properties
+import java.util.zip.ZipFile
+import javax.swing.JFileChooser
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.inputStream
+import kotlin.io.path.outputStream
+import kotlin.io.path.readBytes
+
+/** Per-machine settings (never synced): which folder to sync, and this device's id. */
+class LocalConfig {
+ private val dir: Path = (System.getenv("XDG_CONFIG_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".config")).resolve("rookery")
+ private val file = dir.resolve("config.properties")
+ private val props = Properties().apply { if (file.exists()) file.inputStream().use { load(it) } }
+
+ private fun save() { dir.createDirectories(); file.outputStream().use { props.store(it, "rookery local config") } }
+
+ val deviceId: String = props.getProperty("deviceId") ?: run {
+ val host = runCatching { java.net.InetAddress.getLocalHost().hostName }.getOrNull()?.filter { it.isLetterOrDigit() || it == '-' }?.take(24) ?: "desktop"
+ "$host-${coffee.liz.rookery.randomId().take(6)}".also { props.setProperty("deviceId", it); save() }
+ }
+
+ var syncFolder: String
+ get() = props.getProperty("syncFolder") ?: defaultData().resolve("sync").toString()
+ set(v) { props.setProperty("syncFolder", v); save() }
+
+ private fun defaultData(): Path =
+ (System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery")
+}
+
+class DesktopHost(private val config: LocalConfig) : Host {
+ override fun playChime(notes: List<Chime.Note>) = ChimePlayer.play(notes)
+
+ override suspend fun pickFile(title: String, extensions: List<String>): String? = withContext(Dispatchers.Main) {
+ val d = FileDialog(null as Frame?, title, FileDialog.LOAD)
+ d.setFilenameFilter { _, name -> extensions.any { name.lowercase().endsWith(".$it") } }
+ d.isVisible = true
+ d.file?.let { Paths.get(d.directory, it).toString() }
+ }
+
+ override suspend fun pickFolder(title: String): String? = withContext(Dispatchers.Main) {
+ val c = JFileChooser(config.syncFolder).apply { dialogTitle = title; fileSelectionMode = JFileChooser.DIRECTORIES_ONLY }
+ if (c.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) c.selectedFile.absolutePath else null
+ }
+
+ override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { Paths.get(path).readBytes() }
+
+ override suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?> = withContext(Dispatchers.IO) {
+ if (!path.lowercase().endsWith(".zip")) {
+ val dir = Paths.get(path).parent
+ Paths.get(path).readBytes().decodeToString() to { rel: String -> dir.resolve(rel).normalize().takeIf { it.startsWith(dir) && it.exists() }?.readBytes() }
+ } else {
+ // Read everything up front so the loader works after the zip is closed.
+ val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } }
+ val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") }
+ ?: error("no $mainEntry in ${Paths.get(path).fileName}")
+ val prefix = main.key.removeSuffix(mainEntry ?: "")
+ main.value.decodeToString() to { rel: String -> entries[prefix + rel] }
+ }
+ }
+
+ override val syncFolder: String get() = config.syncFolder
+ override fun setSyncFolder(path: String) { config.syncFolder = path }
+ override val deviceId: String get() = config.deviceId
+}
+
+fun main(args: Array<String>) {
+ if (args.firstOrNull() == "--import") return headlessImport(args.drop(1))
+ if (args.firstOrNull() == "--cube") return cubeCli(args.drop(1))
+ System.setProperty("skiko.renderApi", System.getProperty("skiko.renderApi") ?: "OPENGL")
+ val config = LocalConfig()
+ val root = Paths.get(config.syncFolder).also { it.createDirectories() }
+ val store = Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis)
+ val repo = Repo(store)
+ val scrambler = TnoodleScrambler().apply { warm(Event.E333) }
+
+ application {
+ val state = rememberWindowState(size = DpSize(1280.dp, 820.dp))
+ val scope = rememberCoroutineScope()
+ val model = remember {
+ AppModel(repo, scrambler, DesktopHost(config), scope).apply {
+ // dev hooks for screenshots: ROOKERY_TAB=PAIRS, ROOKERY_PARTY=1, ROOKERY_REPLAY=1, ROOKERY_EVENT=pyram
+ System.getenv("ROOKERY_TAB")?.let { t -> coffee.liz.rookery.ui.Tab.entries.firstOrNull { it.name == t }?.let { tab = it } }
+ System.getenv("ROOKERY_EVENT")?.let { id -> Event.byId(id)?.let { e -> scope.launch { kotlinx.coroutines.delay(1500); createSession(e) } } }
+ if (System.getenv("ROOKERY_REPLAY") != null) scope.launch {
+ kotlinx.coroutines.delay(4000)
+ derived.sessionSolves.lastOrNull { it.moves != null }?.let { selectedSolveId = it.id; startReplay(it) }
+ }
+ if (System.getenv("ROOKERY_PARTY") != null) scope.launch { kotlinx.coroutines.delay(5000); testParty() }
+ }
+ }
+ Window(
+ onCloseRequest = ::exitApplication, state = state, title = "rookery",
+ onPreviewKeyEvent = { handleTimerKey(model, it) },
+ ) {
+ RookeryApp(model)
+ }
+ }
+}
+
+/** `rookery --import <file>...`: import csTimer .txt or Letter Pair Gym .zip files without opening a window. */
+private fun headlessImport(paths: List<String>) {
+ val config = LocalConfig()
+ val root = Paths.get(config.syncFolder).also { it.createDirectories() }
+ val repo = Repo(Store(config.deviceId, FolderLogStorage(root, config.deviceId), FolderBlobStore(root), System::currentTimeMillis))
+ val host = DesktopHost(config)
+ val importer = coffee.liz.rookery.importer.Importer(repo)
+ for (p in paths) {
+ println("» $p")
+ val summary = kotlinx.coroutines.runBlocking {
+ if (p.lowercase().endsWith(".zip") || p.endsWith("backup.json")) {
+ val (text, files) = host.readImport(p, "backup.json")
+ importer.letterPairGym(text, files)
+ } else importer.csTimer(host.readFile(p).decodeToString())
+ }
+ summary.lines.forEach { println(" ✓ $it") }
+ summary.warnings.forEach { println(" ! $it") }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..85a128d
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,8 @@
+# Sized for small machines (this is developed on a 4 GB laptop): one modest Gradle heap,
+# Kotlin compiled inside it rather than in a second daemon, and only two workers at a time.
+org.gradle.jvmargs=-Xmx1280m -XX:MaxMetaspaceSize=384m -XX:+UseSerialGC -Dfile.encoding=UTF-8
+kotlin.compiler.execution.strategy=in-process
+org.gradle.workers.max=2
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..afef406
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,27 @@
+[versions]
+kotlin = "2.4.21"
+compose = "1.12.2"
+coroutines = "1.11.0"
+serialization = "1.13.0"
+tnoodle = "0.20.0"
+dbus-java = "5.2.2"
+
+[libraries]
+coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
+tnoodle-scrambles = { module = "org.worldcubeassociation.tnoodle:lib-scrambles", version.ref = "tnoodle" }
+dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbus-java" }
+dbus-java-transport-native = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbus-java" }
+compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" }
+compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" }
+compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" }
+compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" }
+
+[plugins]
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+compose = { id = "org.jetbrains.compose", version.ref = "compose" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..51cbd2a
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..04d7959
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..03754c4
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,112 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+@rem Catch executions from older scripts and ensure they exit cleanly.
+@rem This can be removed once we can be reasonably confident that few people
+@rem will be migrating directly to this new wrapper.
+goto afterSafetyNet
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+goto exitWithErrorLevel
+:afterSafetyNet
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
+
+@rem This label must not be changed. We rely on old scripts being able to jump to this point.
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..cae62dd
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+rootProject.name = "rookery"
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ google()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ mavenCentral()
+ google()
+ }
+}
+
+include(":core", ":ui", ":desktop")
diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts
new file mode 100644
index 0000000..28e4ef6
--- /dev/null
+++ b/ui/build.gradle.kts
@@ -0,0 +1,26 @@
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.compose)
+}
+
+kotlin {
+ jvmToolchain(21)
+ jvm()
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":core"))
+ api(libs.compose.runtime)
+ api(libs.compose.foundation)
+ api(libs.compose.ui)
+ implementation(libs.compose.resources)
+ implementation(libs.coroutines.core)
+ }
+ }
+}
+
+compose.resources {
+ publicResClass = false
+ packageOfResClass = "coffee.liz.rookery.ui.res"
+}
diff --git a/ui/src/commonMain/composeResources/drawable/penguin_sheet.png b/ui/src/commonMain/composeResources/drawable/penguin_sheet.png
new file mode 100644
index 0000000..24670c1
--- /dev/null
+++ b/ui/src/commonMain/composeResources/drawable/penguin_sheet.png
Binary files differ
diff --git a/ui/src/commonMain/composeResources/font/silkscreen.ttf b/ui/src/commonMain/composeResources/font/silkscreen.ttf
new file mode 100644
index 0000000..8caff81
--- /dev/null
+++ b/ui/src/commonMain/composeResources/font/silkscreen.ttf
Binary files differ
diff --git a/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf b/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf
new file mode 100644
index 0000000..6771252
--- /dev/null
+++ b/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf
Binary files differ
diff --git a/ui/src/commonMain/composeResources/font/vt323.ttf b/ui/src/commonMain/composeResources/font/vt323.ttf
new file mode 100644
index 0000000..afa6909
--- /dev/null
+++ b/ui/src/commonMain/composeResources/font/vt323.ttf
Binary files differ
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
new file mode 100644
index 0000000..3cc79dd
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
@@ -0,0 +1,70 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.timer.TimerMachine.State
+
+@Composable
+fun RookeryApp(m: AppModel) {
+ val dark = when (m.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
+ RookeryTheme(dark) {
+ Box(Modifier.fillMaxSize().background(Rk.c.bg)) {
+ Column(Modifier.fillMaxSize()) {
+ TitleBar("🐧 rookery · ${m.tab.label.lowercase()}")
+ Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it })
+ Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 6.dp).padding(bottom = 6.dp).background(Rk.c.panel).bevel(Rk.c)) {
+ when (m.tab) {
+ Tab.TIMER -> TimerScreen(m)
+ Tab.PAIRS -> PairsScreen(m)
+ Tab.IMPORT -> ImportScreen(m)
+ Tab.SETTINGS -> SettingsScreen(m)
+ }
+ }
+ }
+ m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } }
+ }
+ }
+}
+
+/**
+ * Window-level keys for the timer, csTimer-style: space starts, any key stops, Esc cancels,
+ * Ctrl+1/2/3 set OK/+2/DNF on the last solve. Returns true if consumed.
+ */
+fun handleTimerKey(m: AppModel, e: KeyEvent): Boolean {
+ if (m.tab != Tab.TIMER) return false
+ val running = m.timer is State.Running
+ if (e.isCtrlPressed && e.type == KeyEventType.KeyDown) {
+ val last = m.lastSolve() ?: return false
+ when (e.key) {
+ Key.One -> m.setPenalty(last.id, Penalty.NONE)
+ Key.Two -> m.setPenalty(last.id, Penalty.PLUS2)
+ Key.Three -> m.setPenalty(last.id, Penalty.DNF)
+ else -> return false
+ }
+ return true
+ }
+ return when {
+ e.key == Key.Escape && e.type == KeyEventType.KeyDown -> { m.cancel(); true }
+ running && e.type == KeyEventType.KeyDown -> { m.press(); true }
+ e.key == Key.Spacebar && e.type == KeyEventType.KeyDown -> { m.press(); true }
+ e.key == Key.Spacebar && e.type == KeyEventType.KeyUp -> { m.release(); true }
+ // release after stopping with a non-space key
+ m.timer is State.Stopped && e.type == KeyEventType.KeyUp -> { m.release(); true }
+ else -> false
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
new file mode 100644
index 0000000..db34c7b
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
@@ -0,0 +1,305 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.epochMillis
+import coffee.liz.rookery.importer.Importer
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.LetterPair
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Session
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.monoMillis
+import coffee.liz.rookery.puzzle.PuzzleState
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.puzzle.Turn
+import coffee.liz.rookery.randomId
+import coffee.liz.rookery.scramble.Scrambler
+import coffee.liz.rookery.stats.PersonalBest
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.stats.StatSummary
+import coffee.liz.rookery.stats.newPersonalBests
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.timer.TimerMachine
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the UI needs from the platform it runs on. */
+interface Host {
+ fun playChime(notes: List<Chime.Note>)
+ /** Ask the user for a file; returns its path, or null if cancelled. */
+ suspend fun pickFile(title: String, extensions: List<String>): String?
+ suspend fun pickFolder(title: String): String?
+ /** Read an import file, handling zips: returns the main text and a loader for files inside it. */
+ suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?>
+ suspend fun readFile(path: String): ByteArray
+ val syncFolder: String
+ fun setSyncFolder(path: String)
+ val deviceId: String
+}
+
+enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), IMPORT("Import"), SETTINGS("Settings") }
+enum class ThemeMode { SYSTEM, LIGHT, DARK }
+
+/** Everything derived from the store, recomputed off the UI thread when it changes. */
+data class Derived(
+ val sessions: List<Session> = emptyList(),
+ val session: Session? = null,
+ val sessionSolves: List<Solve> = emptyList(),
+ val sessionStats: StatSummary = StatSummary.of(emptyList()),
+ /** Lifetime stats for the session's event, across all sessions: what PBs are measured against. */
+ val eventStats: StatSummary = StatSummary.of(emptyList()),
+ val pairs: Map<String, LetterPair> = emptyMap(),
+)
+
+data class Party(val title: String, val detail: String)
+
+/** A smart-cube solve being played back in 3D. */
+data class Replay(
+ val solveId: String,
+ val state: PuzzleState,
+ val anim: TurnAnim?,
+ val index: Int,
+ val total: Int,
+ val elapsedMs: Long,
+ val playing: Boolean,
+ /** Set when playback finishes: did the moves actually solve the scramble? */
+ val endedSolved: Boolean? = null,
+)
+
+class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private val scope: CoroutineScope) {
+ var tab by mutableStateOf(Tab.TIMER)
+ var derived by mutableStateOf(Derived())
+ private set
+ var sessionId by mutableStateOf(repo.pref("lastSession"))
+ private set
+
+ val machine = TimerMachine()
+ var timer by mutableStateOf<TimerMachine.State>(TimerMachine.State.Idle)
+ private set
+ var now by mutableLongStateOf(0)
+ private set
+ var lastActivity by mutableLongStateOf(monoMillis())
+ private set
+
+ var scramble by mutableStateOf("")
+ private set
+ var previewSvg by mutableStateOf<String?>(null)
+ private set
+ /** 3D state of the current scramble, for puzzles that have a model. */
+ var previewState by mutableStateOf<PuzzleState?>(null)
+ private set
+ var replay by mutableStateOf<Replay?>(null)
+ private set
+ private var replayJob: kotlinx.coroutines.Job? = null
+ var party by mutableStateOf<Party?>(null)
+ var lastPbs by mutableStateOf<List<PersonalBest>>(emptyList())
+ private set
+ var lastSolveId by mutableStateOf<String?>(null)
+ private set
+ var selectedSolveId by mutableStateOf<String?>(null)
+
+ var inspection by mutableStateOf(repo.boolPref("inspection", true)); private set
+ var sound by mutableStateOf(repo.boolPref("sound", true)); private set
+ var reducedMotion by mutableStateOf(repo.boolPref("reducedMotion", false)); private set
+ var themeMode by mutableStateOf(ThemeMode.entries.firstOrNull { it.name == repo.pref("theme") } ?: ThemeMode.SYSTEM); private set
+
+ var importLog by mutableStateOf<List<String>>(emptyList())
+
+ init {
+ machine.inspection = inspection
+ machine.onFinish = ::finishSolve
+ scope.launch { repo.store.version.collect { refresh() } }
+ scope.launch {
+ // Pick up ops other devices wrote into the sync folder.
+ while (true) { delay(2000); withContext(Dispatchers.Default) { repo.store.sync() } }
+ }
+ }
+
+ val event: Event get() = derived.session?.event ?: Event.E333
+
+ private suspend fun refresh() {
+ val wanted = sessionId
+ val d = withContext(Dispatchers.Default) {
+ val sessions = repo.sessions()
+ val session = sessions.firstOrNull { it.id == wanted } ?: sessions.lastOrNull()
+ val all = repo.solves()
+ val inSession = all.filter { it.sessionId == session?.id }
+ val inEvent = all.filter { it.event == session?.event }
+ Derived(sessions, session, inSession, StatSummary.of(inSession.map { it.result }), StatSummary.of(inEvent.map { it.result }), repo.letterPairs())
+ }
+ if (d.session == null) {
+ createSession(Event.E333)
+ return
+ }
+ val eventChanged = d.session.event != derived.session?.event
+ derived = d
+ if (sessionId != d.session.id) sessionId = d.session.id
+ if (eventChanged || scramble.isEmpty()) newScramble()
+ }
+
+ fun selectSession(id: String) {
+ sessionId = id
+ repo.setPref("lastSession", id)
+ scope.launch { refresh() }
+ }
+
+ fun createSession(event: Event) {
+ val id = randomId()
+ val count = derived.sessions.count { it.event == event }
+ repo.createSession(id, if (count == 0) "Main ${event.short}" else "${event.short} #${count + 1}", event, epochMillis())
+ selectSession(id)
+ }
+
+ fun newScramble() {
+ val e = event
+ scramble = "…"
+ previewSvg = null
+ previewState = null
+ scope.launch {
+ val s = withContext(Dispatchers.Default) { scrambler.next(e) }
+ val svg = withContext(Dispatchers.Default) { scrambler.previewSvg(e, s) }
+ val st = withContext(Dispatchers.Default) { runCatching { Puzzles.of(e.puzzle)?.solved()?.apply(s) }.getOrNull() }
+ if (event == e) { scramble = s; previewSvg = svg; previewState = st }
+ }
+ }
+
+ // ---- timer input ----
+
+ private fun sync() { timer = machine.state; lastActivity = monoMillis() }
+
+ fun press() { machine.press(monoMillis()); sync() }
+ fun release() { machine.release(monoMillis()); sync() }
+ fun cancel() { machine.cancel(); sync() }
+ fun frame() {
+ now = monoMillis()
+ val before = machine.state
+ machine.tick(now)
+ if (machine.state != before) sync()
+ }
+
+ private fun finishSolve(ms: Long, penalty: Penalty) {
+ val session = derived.session ?: return
+ val solve = Solve(randomId(), session.id, session.event, ms, penalty, scramble, "", epochMillis())
+ val before = derived.eventStats
+ val priorResults = repo.solves().filter { it.event == session.event }.map { it.result }
+ repo.addSolve(solve)
+ lastSolveId = solve.id
+ newScramble()
+ scope.launch {
+ val after = withContext(Dispatchers.Default) { StatSummary.of(priorResults + solve.result) }
+ // Celebrate only beating a real previous best, and skip mo3 (too frequent to be special).
+ val pbs = newPersonalBests(before, after).filter { it.previous != null && it.kind != StatKind.MO3 }
+ lastPbs = pbs
+ if (pbs.isNotEmpty()) celebrate(pbs)
+ else if (solve.result == DNF && sound) host.playChime(Chime.DNF)
+ }
+ }
+
+ private fun celebrate(pbs: List<PersonalBest>) {
+ val top = pbs.maxBy { it.kind.n }
+ val title = if (top.kind == StatKind.SINGLE) "NEW PB!" else "NEW ${top.kind.label.uppercase()} PB!"
+ val detail = pbs.joinToString(" ") { "${it.kind.label} ${formatTime(it.value, round = it.kind != StatKind.SINGLE)}" }
+ party = Party(title, detail)
+ if (sound) host.playChime(if (top.kind == StatKind.SINGLE) Chime.PB else Chime.AVERAGE_PB)
+ }
+
+ // ---- smart-cube replay ----
+
+ /** Play back a solve's recorded moves (csTimer `R@0 U'@120 ...`) at real speed. */
+ fun startReplay(solve: Solve) {
+ val moves = solve.moves ?: return
+ replayJob?.cancel()
+ replayJob = scope.launch {
+ val puzzle = Puzzles.cube(3)
+ val timed: List<Pair<Turn, Long>> = withContext(Dispatchers.Default) {
+ moves.split(' ').filter { '@' in it }.mapNotNull { tok ->
+ val (mv, t) = tok.split('@', limit = 2)
+ runCatching { puzzle.parse(mv).single() to t.toLong() }.getOrNull()
+ }
+ }
+ var st = puzzle.solved().apply(solve.scramble)
+ replay = Replay(solve.id, st, null, 0, timed.size, 0, playing = true)
+ val start = monoMillis()
+ for ((k, pair) in timed.withIndex()) {
+ val (turn, at) = pair
+ while (monoMillis() - start < at) { replay = replay?.copy(elapsedMs = monoMillis() - start); delay(16) }
+ val next = timed.getOrNull(k + 1)?.second ?: (at + 200)
+ val dur = (next - at).coerceIn(30, 110)
+ val t0 = monoMillis()
+ while (true) {
+ val f = ((monoMillis() - t0).toFloat() / dur).coerceAtMost(1f)
+ replay = replay?.copy(anim = TurnAnim(turn, f), elapsedMs = monoMillis() - start)
+ if (f >= 1f) break
+ delay(16)
+ }
+ st = st.apply(turn)
+ replay = replay?.copy(state = st, anim = null, index = k + 1)
+ }
+ replay = replay?.copy(playing = false, endedSolved = st.isSolved, elapsedMs = timed.lastOrNull()?.second ?: 0)
+ }
+ }
+
+ fun stopReplay() { replayJob?.cancel(); replay = null }
+
+ // ---- solve edits ----
+
+ fun setPenalty(id: String, p: Penalty) = repo.setPenalty(id, p)
+ fun deleteSolve(id: String) { repo.deleteSolve(id); if (selectedSolveId == id) selectedSolveId = null }
+ fun lastSolve(): Solve? = derived.sessionSolves.lastOrNull()
+
+ // ---- prefs ----
+
+ fun changeInspection(on: Boolean) { inspection = on; machine.inspection = on; repo.setPref("inspection", on) }
+ fun changeSound(on: Boolean) { sound = on; repo.setPref("sound", on) }
+ fun changeReducedMotion(on: Boolean) { reducedMotion = on; repo.setPref("reducedMotion", on) }
+ fun setTheme(m: ThemeMode) { themeMode = m; repo.setPref("theme", m.name) }
+ fun testChime() = host.playChime(Chime.PB)
+ fun testParty() = celebrate(listOf(PersonalBest(StatKind.SINGLE, 9_990, 10_420)))
+
+ // ---- imports ----
+
+ fun importCsTimer() = scope.launch {
+ val path = host.pickFile("Import csTimer export", listOf("txt", "json")) ?: return@launch
+ runImport("csTimer: $path") { Importer(repo).csTimer(host.readFile(path).decodeToString()) }
+ }
+
+ fun importLetterPairGym() = scope.launch {
+ val path = host.pickFile("Import Letter Pair Gym export", listOf("zip", "json")) ?: return@launch
+ runImport("Letter Pair Gym: $path") {
+ val (text, files) = host.readImport(path, "backup.json")
+ Importer(repo).letterPairGym(text, files)
+ }
+ }
+
+ private suspend fun runImport(what: String, block: suspend () -> Importer.Summary) {
+ importLog = importLog + "» importing $what"
+ try {
+ val s = withContext(Dispatchers.Default) { block() }
+ importLog = importLog + s.lines.map { " ✓ $it" } + s.warnings.map { " ! $it" }
+ } catch (e: Exception) {
+ importLog = importLog + " ✗ failed: ${e.message}"
+ }
+ }
+
+ // ---- letter pairs ----
+
+ fun setPrimaryWord(pair: String, word: String) = repo.setPrimaryWord(pair, word.trim(), epochMillis())
+ fun setPairNote(pair: String, note: String) = repo.setPairNote(pair, note.ifBlank { null })
+ fun removeWord(pair: String, word: String) = repo.setWordInSet(pair, word, false)
+ fun addWord(pair: String, word: String) { if (word.isNotBlank()) repo.setWordInSet(pair, word.trim(), true) }
+ fun setPairImageFromFile(pair: String) = scope.launch {
+ val path = host.pickFile("Image for $pair", listOf("jpg", "jpeg", "png", "webp", "gif")) ?: return@launch
+ val bytes = host.readFile(path)
+ repo.setPairImage(pair, repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg")))
+ }
+ fun blob(name: String): ByteArray? = repo.store.blobs.get(name)
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
new file mode 100644
index 0000000..b861331
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
@@ -0,0 +1,112 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+@Composable
+fun ImportScreen(m: AppModel) {
+ Column(Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Import", Modifier.widthIn(max = 720.dp)) {
+ Label("Imports are safe to repeat: anything already here is skipped.", color = Rk.c.inkDim)
+ Spacer(Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroButton("csTimer export…", { m.importCsTimer() })
+ Spacer(Modifier.width(8.dp))
+ Label("the .txt from csTimer's Export → \"Export to file\"", color = Rk.c.inkDim)
+ }
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroButton("Letter Pair Gym export…", { m.importLetterPairGym() })
+ Spacer(Modifier.width(8.dp))
+ Label("the .zip (words, notes, images, history, stats)", color = Rk.c.inkDim)
+ }
+ }
+ if (m.importLog.isNotEmpty()) GroupBox("Log", Modifier.widthIn(max = 720.dp)) {
+ Column(Modifier.fillMaxWidth().background(Rk.c.field).bevel(Rk.c, sunken = true).padding(8.dp)) {
+ for (line in m.importLog) Label(
+ line, style = Rk.t.mono.copy(fontSize = Rk.t.mono.fontSize * 0.9f),
+ color = when { line.trimStart().startsWith("✗") -> Rk.c.bad; line.trimStart().startsWith("!") -> Rk.c.gold; else -> Rk.c.ink },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(m: AppModel) {
+ val scope = rememberCoroutineScope()
+ var syncPath by remember { mutableStateOf(m.host.syncFolder) }
+ var moved by remember { mutableStateOf(false) }
+ Column(Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Timer", Modifier.widthIn(max = 720.dp)) {
+ Checkbox("WCA inspection (15s, +2 after 15s, DNF after 17s)", m.inspection, m::changeInspection)
+ Checkbox("Sounds (PB chime, DNF blip)", m.sound, m::changeSound)
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ RetroButton("Play PB chime", { m.testChime() })
+ RetroButton("Throw a test party", { m.testParty() })
+ }
+ }
+ GroupBox("Look", Modifier.widthIn(max = 720.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("Theme:", style = Rk.t.label)
+ for (t in ThemeMode.entries) RetroButton(t.name.lowercase(), { m.setTheme(t) }, selected = m.themeMode == t)
+ }
+ Checkbox("Reduce motion (no confetti, still penguins)", m.reducedMotion, m::changeReducedMotion)
+ }
+ GroupBox("Sync", Modifier.widthIn(max = 720.dp)) {
+ Label(
+ "Everything lives in this folder as per-device append-only logs. Point it at a Syncthing (or Drive, Dropbox, …) " +
+ "folder and every device merges automatically. Each device only writes its own files, so syncing never conflicts.",
+ color = Rk.c.inkDim,
+ )
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(syncPath, { syncPath = it }, Modifier.weight(1f), description = "sync folder")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Browse…", { scope.launch { m.host.pickFolder("Sync folder")?.let { syncPath = it } } })
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Apply", { m.host.setSyncFolder(syncPath); moved = true }, enabled = syncPath != m.host.syncFolder)
+ }
+ if (moved) Label("Saved. Restart Rookery to switch folders.", color = Rk.c.gold)
+ Label("This device: ${m.host.deviceId}", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ GroupBox("Smart cubes", Modifier.widthIn(max = 720.dp)) {
+ val devices = m.repo.devices()
+ if (devices.isEmpty()) Label("None yet. Import from csTimer, or pair one (coming in the smart-cube phase).", color = Rk.c.inkDim)
+ else {
+ Row { HeaderCell("name", 2f); HeaderCell("MAC", 2f); HeaderCell("protocol", 1f) }
+ for ((i, d) in devices.withIndex()) Row(Modifier.background(if (i % 2 == 1) Rk.c.zebra else Color.Transparent)) {
+ Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f); Cell(d.protocol, 1f)
+ }
+ }
+ }
+ GroupBox("Blindfolded", Modifier.widthIn(max = 720.dp)) {
+ Label("Orientation: yellow top, orange front · Speffz lettering", style = Rk.t.body)
+ Label("Corners: Old Pochmann, buffer UBL (A), target RDF (P)", style = Rk.t.body)
+ Label("Edges: buffer UR (B), targets UL (D, T-perm) · UB (A, Ja) · UF (C, Jb) · parity: R-perm", style = Rk.t.body)
+ Label("Editable when the BLD trainers land.", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
new file mode 100644
index 0000000..8bcd47b
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
@@ -0,0 +1,180 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.model.LetterPair
+import org.jetbrains.compose.resources.decodeToImageBitmap
+
+const val LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWX"
+
+@Composable
+fun PairsScreen(m: AppModel) {
+ var query by remember { mutableStateOf("") }
+ var selected by remember { mutableStateOf<String?>(null) }
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ val glance = query.uppercase().filter { it in LETTERS }.take(2)
+ val shown = if (glance.length == 2) glance else selected
+
+ Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ GroupBox("Glance", Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(
+ query, { query = it.take(2) }, Modifier.width(120.dp).focusRequester(focus), placeholder = "AB",
+ style = Rk.t.mono.copy(fontSize = 34.sp), description = "type a letter pair",
+ )
+ Spacer(Modifier.width(12.dp))
+ val n = m.derived.pairs.values.count { it.primary != null }
+ Label("Type two letters to look up a pair. $n of ${LETTERS.length * (LETTERS.length - 1)} pairs have words.", color = Rk.c.inkDim)
+ }
+ }
+ PairGrid(m.derived.pairs, shown) { selected = it; query = "" }
+ }
+ Column(Modifier.width(380.dp).fillMaxHeight()) {
+ if (shown != null) PairCard(m, shown, m.derived.pairs[shown])
+ else GroupBox("Pair", Modifier.fillMaxWidth()) { Label("Pick a cell, or type a pair.", color = Rk.c.inkDim) }
+ }
+ }
+}
+
+@Composable
+private fun PairGrid(pairs: Map<String, LetterPair>, selected: String?, onPick: (String) -> Unit) {
+ val p = Rk.c
+ val cellW = 74.dp
+ val cellH = 24.dp
+ Panel(Modifier.fillMaxSize(), padding = androidx.compose.foundation.layout.PaddingValues(2.dp)) {
+ Row(Modifier.padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("heat = recent misses", style = Rk.t.label, color = p.inkDim)
+ for (d in listOf(0.0, 0.33, 0.66, 1.0)) Box(Modifier.padding(start = 4.dp).size(14.dp).background(heat(p, d)).outline(p.bevelLo))
+ }
+ Box(Modifier.fillMaxSize().background(p.field).bevel(p, sunken = true).verticalScroll(rememberScrollState()).horizontalScroll(rememberScrollState())) {
+ Column {
+ Row {
+ Box(Modifier.size(28.dp, cellH).background(p.panel).bevel(p))
+ for (b in LETTERS) Box(Modifier.size(cellW, cellH).background(p.panel).bevel(p), Alignment.Center) {
+ BasicText("$b", style = Rk.t.heading.copy(color = p.ink))
+ }
+ }
+ for (a in LETTERS) Row {
+ Box(Modifier.size(28.dp, cellH).background(p.panel).bevel(p), Alignment.Center) { BasicText("$a", style = Rk.t.heading.copy(color = p.ink)) }
+ for (b in LETTERS) {
+ val key = "$a$b"
+ val lp = pairs[key]
+ val sel = key == selected
+ val bg = when {
+ a == b -> p.panel
+ sel -> p.select
+ lp?.difficulty != null -> heat(p, lp.difficulty!!)
+ else -> Color.Transparent
+ }
+ Box(
+ Modifier.size(cellW, cellH).background(bg).outline(p.zebra)
+ .then(if (a != b) Modifier.clickable { onPick(key) } else Modifier)
+ .semantics { contentDescription = "$key ${lp?.primary ?: "no word"}" }
+ .padding(horizontal = 3.dp),
+ Alignment.CenterStart,
+ ) {
+ if (a != b) BasicText(
+ lp?.primary ?: "·", maxLines = 1, overflow = TextOverflow.Ellipsis,
+ style = Rk.t.body.copy(fontSize = 11.sp, color = if (sel) p.selectInk else if (lp?.primary == null) p.inkDim else p.ink),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun heat(p: Palette, d: Double): Color {
+ val cold = if (p.dark) Color(0xFF15303F) else Color(0xFFDDEFF8)
+ val hot = if (p.dark) Color(0xFF6B2020) else Color(0xFFF4B4AE)
+ return lerp(cold, hot, d.toFloat().coerceIn(0f, 1f))
+}
+
+@Composable
+private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) {
+ var word by remember(pair, lp?.primary) { mutableStateOf(lp?.primary.orEmpty()) }
+ var note by remember(pair, lp?.note) { mutableStateOf(lp?.note.orEmpty()) }
+ var alt by remember(pair) { mutableStateOf("") }
+ val image: ImageBitmap? = remember(lp?.image) {
+ lp?.image?.let { name -> runCatching { m.blob(name)?.decodeToImageBitmap() }.getOrNull() }
+ }
+ Panel(Modifier.fillMaxSize()) {
+ TitleBar(pair)
+ Spacer(Modifier.height(8.dp))
+ Box(Modifier.fillMaxWidth().height(240.dp).background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) {
+ if (image != null) Image(image, lp?.primary ?: pair, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit)
+ else Label("no image", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+ Spacer(Modifier.height(6.dp))
+ BasicText(lp?.primary ?: "—", Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 44.sp, color = Rk.c.ink, textAlign = TextAlign.Center))
+ lp?.note?.let { Label(it, Modifier.fillMaxWidth(), color = Rk.c.inkDim) }
+ Hr()
+ Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Label("Main word", style = Rk.t.label)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(word, { word = it }, Modifier.weight(1f), onSubmit = { m.setPrimaryWord(pair, word) }, description = "main word for $pair")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Save", { m.setPrimaryWord(pair, word) })
+ }
+ Label("Note", style = Rk.t.label)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(note, { note = it }, Modifier.weight(1f), onSubmit = { m.setPairNote(pair, note) }, description = "note for $pair")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Save", { m.setPairNote(pair, note) })
+ }
+ Label("Other words", style = Rk.t.label)
+ for (w in lp?.words.orEmpty().filter { it != lp?.primary }.sorted()) Row(verticalAlignment = Alignment.CenterVertically) {
+ Label(w, Modifier.weight(1f))
+ RetroButton("Use", { m.setPrimaryWord(pair, w) }, description = "make $w the main word")
+ Spacer(Modifier.width(4.dp))
+ RetroButton("✕", { m.removeWord(pair, w) }, description = "remove $w")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(alt, { alt = it }, Modifier.weight(1f), placeholder = "add a word…", onSubmit = { m.addWord(pair, alt); alt = "" })
+ Spacer(Modifier.width(4.dp))
+ RetroButton("Add", { m.addWord(pair, alt); alt = "" })
+ }
+ RetroButton("Image from file…", { m.setPairImageFromFile(pair) })
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
new file mode 100644
index 0000000..4da7994
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
@@ -0,0 +1,176 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.ui.res.Res
+import coffee.liz.rookery.ui.res.penguin_sheet
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.imageResource
+import kotlin.random.Random
+
+/**
+ * The penguin from liz.coffee: a 8x4 sheet of 32px frames in the oneko.js layout.
+ * Each animation is a list of (col, row) cells.
+ */
+enum class PenguinAnim(vararg cells: Pair<Int, Int>) {
+ IDLE(3 to 3), ALERT(7 to 3), PECK(5 to 0, 6 to 0, 7 to 0), TIRED(3 to 2), SLEEP(2 to 0, 2 to 1),
+ N(1 to 2, 1 to 3), NE(0 to 2, 0 to 3), E(3 to 0, 3 to 1), SE(5 to 1, 5 to 2),
+ S(6 to 3, 7 to 2), SW(5 to 3, 6 to 1), W(4 to 2, 4 to 3), NW(1 to 0, 1 to 1),
+ WALL_N(0 to 0, 0 to 1);
+
+ val frames: List<Pair<Int, Int>> = cells.toList()
+}
+
+const val SPRITE = 32
+
+@Composable
+fun penguinSheet(): ImageBitmap = imageResource(Res.drawable.penguin_sheet)
+
+fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, scale: Float, alpha: Float = 1f) {
+ drawImage(
+ sheet,
+ srcOffset = IntOffset(cell.first * SPRITE, cell.second * SPRITE),
+ srcSize = IntSize(SPRITE, SPRITE),
+ dstOffset = IntOffset(at.x.toInt(), at.y.toInt()),
+ dstSize = IntSize((SPRITE * scale).toInt(), (SPRITE * scale).toInt()),
+ alpha = alpha,
+ filterQuality = FilterQuality.None,
+ )
+}
+
+/** A single animated penguin. Static first frame when [reducedMotion]. */
+@Composable
+fun Penguin(anim: PenguinAnim, size: Dp = 64.dp, reducedMotion: Boolean = false, modifier: Modifier = Modifier, frameMs: Long = 220) {
+ val sheet = penguinSheet()
+ var frame by remember(anim) { mutableIntStateOf(0) }
+ LaunchedEffect(anim, reducedMotion) {
+ if (reducedMotion || anim.frames.size == 1) return@LaunchedEffect
+ while (true) { delay(frameMs); frame++ }
+ }
+ Canvas(modifier.size(size).semantics { contentDescription = "penguin, ${anim.name.lowercase()}" }) {
+ drawPenguin(sheet, anim.frames[frame % anim.frames.size], Offset.Zero, this.size.width / SPRITE)
+ }
+}
+
+/** What the mascot is up to, driven by the timer. */
+enum class Mood { IDLE, WATCHING, READY, SOLVING, HAPPY, SAD, SLEEPY }
+
+/** The mascot wanders along a strip, reacting to [mood]. */
+@Composable
+fun MascotStrip(mood: Mood, reducedMotion: Boolean, modifier: Modifier = Modifier, scale: Float = 2f) {
+ val sheet = penguinSheet()
+ BoxWithConstraints(modifier) {
+ val widthPx = constraints.maxWidth.toFloat()
+ var x by remember { mutableStateOf(40f) }
+ var target by remember { mutableStateOf(40f) }
+ var tick by remember { mutableLongStateOf(0) }
+ LaunchedEffect(mood, reducedMotion, widthPx) {
+ var last = 0L
+ while (true) {
+ withFrameMillis { t ->
+ if (t - last > 120) { tick++; last = t }
+ val spriteW = SPRITE * scale
+ val maxX = (widthPx - spriteW).coerceAtLeast(0f)
+ when (mood) {
+ Mood.IDLE -> {
+ if (!reducedMotion && kotlin.math.abs(target - x) < 2f && Random.nextInt(400) == 0) target = Random.nextFloat() * maxX
+ if (!reducedMotion) x += (target - x).coerceIn(-1.6f, 1.6f)
+ }
+ Mood.HAPPY -> if (!reducedMotion) { target = (x + 3f).let { if (it > maxX) 0f else it }; x = target }
+ else -> target = x
+ }
+ x = x.coerceIn(0f, maxX)
+ }
+ }
+ }
+ val anim = when (mood) {
+ Mood.IDLE -> if (kotlin.math.abs(target - x) >= 2f) (if (target > x) PenguinAnim.E else PenguinAnim.W) else PenguinAnim.IDLE
+ Mood.WATCHING -> PenguinAnim.S
+ Mood.READY -> PenguinAnim.ALERT
+ Mood.SOLVING -> PenguinAnim.N
+ Mood.HAPPY -> PenguinAnim.E
+ Mood.SAD -> PenguinAnim.PECK
+ Mood.SLEEPY -> PenguinAnim.SLEEP
+ }
+ val slow = if (anim == PenguinAnim.SLEEP) 6 else 2
+ Canvas(Modifier.fillMaxSize().semantics { contentDescription = "penguin mascot, ${mood.name.lowercase()}" }) {
+ val f = if (reducedMotion) 0 else ((tick / slow) % anim.frames.size).toInt()
+ drawPenguin(sheet, anim.frames[f], Offset(x, size.height - SPRITE * scale), scale)
+ }
+ }
+}
+
+/** Full-screen PB celebration: penguins rain down with pixel confetti and a blinking banner. */
+@Composable
+fun PbParty(title: String, detail: String, reducedMotion: Boolean, onDone: () -> Unit) {
+ val sheet = penguinSheet()
+ val p = Rk.c
+ class Bit(var x: Float, var y: Float, val vx: Float, val vy: Float, val penguin: Boolean, val color: Color, val anim: PenguinAnim, val size: Float)
+ val palette = listOf(Color(0xFFFFD54F), Color(0xFF7CC4FF), Color(0xFFFF8A80), Color(0xFF8CFF9E), Color(0xFFE1BEE7), Color.White)
+ val bits = remember {
+ List(70) { i ->
+ val penguin = i % 4 == 0
+ Bit(Random.nextFloat(), 0.1f - Random.nextFloat() * 0.8f, (Random.nextFloat() - 0.5f) * 0.0004f, 0.00025f + Random.nextFloat() * 0.0005f,
+ penguin, palette.random(), listOf(PenguinAnim.S, PenguinAnim.ALERT, PenguinAnim.SE, PenguinAnim.SW).random(), if (penguin) 2f else 6f + Random.nextInt(6))
+ }
+ }
+ var t by remember { mutableLongStateOf(0) }
+ LaunchedEffect(Unit) {
+ val start = withFrameMillis { it }
+ while (true) {
+ val now = withFrameMillis { it }
+ t = now - start
+ if (t > 4200) { onDone(); break }
+ }
+ }
+ Box(Modifier.fillMaxSize().semantics { contentDescription = "$title $detail" }) {
+ if (!reducedMotion) Canvas(Modifier.fillMaxSize()) {
+ for (b in bits) {
+ val x = (b.x + b.vx * t) * size.width
+ val y = (b.y + b.vy * t) * size.height
+ if (b.penguin) {
+ drawPenguin(sheet, b.anim.frames[((t / 200) % b.anim.frames.size).toInt()], Offset(x, y), b.size)
+ } else {
+ drawRect(b.color, Offset(x, y), Size(b.size, b.size))
+ }
+ }
+ }
+ val blinkOn = reducedMotion || (t / 300) % 2 == 0L
+ Box(Modifier.align(Alignment.Center).background(p.titleA).bevel(p).padding(horizontal = 28.dp, vertical = 16.dp), contentAlignment = Alignment.Center) {
+ androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ BasicText("★ $title ★", style = Rk.t.heading.copy(fontSize = 28.sp, color = if (blinkOn) p.gold else p.titleInk))
+ BasicText(detail, Modifier.padding(top = 6.dp), style = Rk.t.mono.copy(fontSize = 30.sp, color = p.titleInk))
+ }
+ }
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
new file mode 100644
index 0000000..41ea00f
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
@@ -0,0 +1,124 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import coffee.liz.rookery.puzzle.Polygon
+import coffee.liz.rookery.puzzle.PuzzleState
+import coffee.liz.rookery.puzzle.Turn
+import coffee.liz.rookery.puzzle.V3
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.sin
+
+/** A turn in flight: draw [turn]'s layers rotated [progress] (0..1) of the way. */
+data class TurnAnim(val turn: Turn, val progress: Float)
+
+/** Camera angles in degrees; hoisted so callers can reset or remember them. */
+class Camera(yaw: Float = -32f, pitch: Float = 28f) {
+ var yaw by mutableFloatStateOf(yaw)
+ var pitch by mutableFloatStateOf(pitch)
+}
+
+/**
+ * Flat-shaded 3D puzzle, drawn with the painter's algorithm on a Canvas: each sticker is a black
+ * "plastic" tile with an inset coloured sticker, lit by a soft key light. Drag to orbit.
+ */
+@Composable
+fun PuzzleView(
+ state: PuzzleState,
+ faceColors: List<Color>,
+ modifier: Modifier = Modifier,
+ camera: Camera = remember { Camera() },
+ anim: TurnAnim? = null,
+ description: String = "3D puzzle view",
+) {
+ val puzzle = state.puzzle
+ val body = Color(0xFF14181C)
+ Canvas(
+ modifier
+ .pointerInput(camera) {
+ detectDragGestures { change, drag ->
+ change.consume()
+ camera.yaw += drag.x * 0.45f
+ camera.pitch = (camera.pitch + drag.y * 0.45f).coerceIn(-89f, 89f)
+ }
+ }
+ .semantics { contentDescription = description },
+ ) {
+ val yaw = camera.yaw * PI / 180
+ val pitch = camera.pitch * PI / 180
+ // world → view: yaw about y, then pitch about x
+ fun view(p: V3): V3 {
+ val x1 = p.x * cos(yaw) + p.z * sin(yaw)
+ val z1 = -p.x * sin(yaw) + p.z * cos(yaw)
+ val y2 = p.y * cos(pitch) - z1 * sin(pitch)
+ val z2 = p.y * sin(pitch) + z1 * cos(pitch)
+ return V3(x1, y2, z2)
+ }
+ val radius = puzzle.stickers.maxOf { s -> s.points.maxOf { it.length } }
+ val dist = radius * 4.5
+ val scale = min(size.width, size.height) / 2f / radius.toFloat() * 0.80f
+ val cx = size.width / 2; val cy = size.height / 2
+ fun project(p: V3): Offset {
+ val k = (dist / (dist - p.z)).toFloat()
+ return Offset(cx + p.x.toFloat() * scale * k, cy - p.y.toFloat() * scale * k)
+ }
+ val light = V3(-0.4, 0.75, 0.55).unit()
+
+ val turning = anim?.let { a ->
+ val axis = puzzle.axes[a.turn.axis]
+ val angle = -2 * PI * a.turn.amount / axis.order * a.progress
+ Triple(a.turn, axis.normal, angle)
+ }
+
+ class Drawn(val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
+ val drawn = ArrayList<Drawn>(puzzle.stickers.size)
+ for ((i, s) in puzzle.stickers.withIndex()) {
+ var poly = s
+ if (turning != null && puzzle.slabOf(turning.first.axis, i) in turning.first.layers) {
+ poly = poly.map { it.rotate(turning.second, turning.third) }
+ }
+ val v = poly.map(::view)
+ val toCam = V3(0.0, 0.0, dist) - v.centroid
+ val n = v.normal
+ if ((n dot toCam) <= 0) continue // back face
+ val lambert = maxOf(0.0, n dot light).toFloat()
+ drawn += Drawn(v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
+ }
+ drawn.sortBy { it.depth }
+ for (d in drawn) {
+ fill(d.tile.points.map(::project), body)
+ val sticker = d.tile.inset(0.86).points.map(::project)
+ fill(sticker, d.color.shaded(d.shade))
+ // a thin highlight on the sticker's upper edge for a slightly glossy, skeuomorphic feel
+ drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1f))
+ }
+ }
+}
+
+private fun path(pts: List<Offset>) = Path().apply {
+ moveTo(pts[0].x, pts[0].y)
+ for (k in 1 until pts.size) lineTo(pts[k].x, pts[k].y)
+ close()
+}
+
+private fun DrawScope.fill(pts: List<Offset>, c: Color) = drawPath(path(pts), c)
+
+private fun Color.shaded(k: Float) = Color(red * k, green * k, blue * k, alpha)
+
+fun rgb(hex: Long) = Color(0xFF000000 or hex)
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
new file mode 100644
index 0000000..2fe6be5
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
@@ -0,0 +1,225 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Classic two-tone 3D border: light top-left, dark bottom-right (swapped when [sunken]). */
+fun Modifier.bevel(p: Palette, sunken: Boolean = false, width: Dp = 2.dp): Modifier = drawBehind {
+ val w = width.toPx()
+ val hi = if (sunken) p.bevelLo else p.bevelHi
+ val lo = if (sunken) p.bevelHi else p.bevelLo
+ val outer = if (sunken) p.bevelDark else p.bevelDark
+ // outer frame
+ drawRect(if (sunken) outer else hi, Offset.Zero, androidx.compose.ui.geometry.Size(size.width, w / 2))
+ drawRect(if (sunken) outer else hi, Offset.Zero, androidx.compose.ui.geometry.Size(w / 2, size.height))
+ drawRect(if (sunken) hi else outer, Offset(0f, size.height - w / 2), androidx.compose.ui.geometry.Size(size.width, w / 2))
+ drawRect(if (sunken) hi else outer, Offset(size.width - w / 2, 0f), androidx.compose.ui.geometry.Size(w / 2, size.height))
+ // inner frame
+ drawRect(if (sunken) lo else hi, Offset(w / 2, w / 2), androidx.compose.ui.geometry.Size(size.width - w, w / 2))
+ drawRect(if (sunken) lo else hi, Offset(w / 2, w / 2), androidx.compose.ui.geometry.Size(w / 2, size.height - w))
+ drawRect(if (sunken) hi else lo, Offset(w / 2, size.height - w), androidx.compose.ui.geometry.Size(size.width - w, w / 2))
+ drawRect(if (sunken) hi else lo, Offset(size.width - w, w / 2), androidx.compose.ui.geometry.Size(w / 2, size.height - w))
+}
+
+/** Win98-style dotted focus ring, so keyboard users always see where they are. */
+fun Modifier.focusRing(focused: Boolean, color: Color): Modifier = if (!focused) this else drawBehind {
+ drawRect(color, topLeft = Offset(4f, 4f), size = androidx.compose.ui.geometry.Size(size.width - 8f, size.height - 8f),
+ style = Stroke(1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 2f))))
+}
+
+@Composable
+fun Panel(modifier: Modifier = Modifier, sunken: Boolean = false, padding: PaddingValues = PaddingValues(8.dp), content: @Composable ColumnScope.() -> Unit) {
+ val p = Rk.c
+ Column(modifier.background(if (sunken) p.field else p.panel).bevel(p, sunken).padding(padding), content = content)
+}
+
+/** A titled group box, like a <fieldset>. */
+@Composable
+fun GroupBox(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
+ Panel(modifier) {
+ BasicText(title.uppercase(), style = Rk.t.heading.copy(color = Rk.c.ink))
+ Box(Modifier.height(6.dp))
+ content()
+ }
+}
+
+@Composable
+fun TitleBar(text: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
+ val p = Rk.c
+ Row(
+ modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(p.titleA, p.titleB))).padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BasicText(text, Modifier.weight(1f), style = Rk.t.heading.copy(color = p.titleInk))
+ trailing()
+ }
+}
+
+@Composable
+fun RetroButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ selected: Boolean = false,
+ description: String? = null,
+ style: TextStyle? = null,
+) {
+ val p = Rk.c
+ val src = remember { MutableInteractionSource() }
+ val pressed by src.collectIsPressedAsState()
+ val focused by src.collectIsFocusedAsState()
+ val sunken = pressed || selected
+ Box(
+ modifier
+ .background(if (selected) p.zebra else p.panel)
+ .bevel(p, sunken)
+ .focusRing(focused, p.ink)
+ .clickable(src, indication = null, enabled = enabled, role = Role.Button, onClick = onClick)
+ .semantics { if (description != null) contentDescription = description; this.selected = selected }
+ .padding(horizontal = 12.dp, vertical = 6.dp)
+ .padding(start = if (sunken) 1.dp else 0.dp, top = if (sunken) 1.dp else 0.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ BasicText(text, style = (style ?: Rk.t.label).copy(color = if (enabled) p.ink else p.inkDim), maxLines = 1)
+ }
+}
+
+/** Tab strip; the selected tab merges into the panel below it. */
+@Composable
+fun <T> Tabs(items: List<T>, selected: T, label: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier) {
+ val p = Rk.c
+ Row(modifier.padding(start = 6.dp, top = 6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
+ for (item in items) {
+ val sel = item == selected
+ val src = remember { MutableInteractionSource() }
+ val focused by src.collectIsFocusedAsState()
+ Box(
+ Modifier
+ .background(if (sel) p.panel else p.zebra)
+ .bevel(p)
+ .focusRing(focused, p.ink)
+ .clickable(src, null, role = Role.Tab) { onSelect(item) }
+ .semantics { this.selected = sel }
+ .padding(horizontal = 14.dp, vertical = if (sel) 8.dp else 5.dp),
+ ) { BasicText(label(item), style = Rk.t.label.copy(color = p.ink)) }
+ }
+ }
+}
+
+@Composable
+fun Label(text: String, modifier: Modifier = Modifier, color: Color? = null, style: TextStyle? = null, maxLines: Int = Int.MAX_VALUE) {
+ BasicText(text, modifier, style = (style ?: Rk.t.body).copy(color = color ?: Rk.c.ink), maxLines = maxLines, overflow = TextOverflow.Ellipsis)
+}
+
+@Composable
+fun RetroField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: String = "",
+ style: TextStyle? = null,
+ singleLine: Boolean = true,
+ onSubmit: (() -> Unit)? = null,
+ description: String? = null,
+) {
+ val p = Rk.c
+ val st = (style ?: Rk.t.body).copy(color = p.ink)
+ BasicTextField(
+ value, onValueChange,
+ modifier.background(p.field).bevel(p, sunken = true).padding(horizontal = 8.dp, vertical = 6.dp)
+ .semantics { if (description != null) contentDescription = description },
+ textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink),
+ keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default),
+ keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }),
+ decorationBox = { inner ->
+ Box {
+ if (value.isEmpty()) BasicText(placeholder, style = st.copy(color = p.inkDim))
+ inner()
+ }
+ },
+ )
+}
+
+@Composable
+fun Checkbox(label: String, checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
+ val p = Rk.c
+ val src = remember { MutableInteractionSource() }
+ val focused by src.collectIsFocusedAsState()
+ Row(
+ modifier.focusRing(focused, p.ink).clickable(src, null, role = Role.Checkbox) { onChange(!checked) }.padding(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(16.dp).background(p.field).bevel(p, sunken = true), contentAlignment = Alignment.Center) {
+ if (checked) BasicText("✓", style = Rk.t.body.copy(color = p.ink))
+ }
+ Box(Modifier.width(8.dp))
+ Label(label)
+ }
+}
+
+/** Header cell for <table>-style grids. */
+@Composable
+fun RowScope.HeaderCell(text: String, weight: Float, modifier: Modifier = Modifier) {
+ Box(modifier.weight(weight).background(Rk.c.panel).bevel(Rk.c).padding(horizontal = 6.dp, vertical = 3.dp)) {
+ BasicText(text, style = Rk.t.label.copy(color = Rk.c.ink), maxLines = 1)
+ }
+}
+
+@Composable
+fun RowScope.Cell(text: String, weight: Float, color: Color? = null, style: TextStyle? = null, modifier: Modifier = Modifier) {
+ Box(modifier.weight(weight).padding(horizontal = 6.dp, vertical = 3.dp)) {
+ BasicText(text, style = (style ?: Rk.t.mono.copy(fontSize = Rk.t.mono.fontSize)).copy(color = color ?: Rk.c.ink), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+@Composable
+fun Hr(modifier: Modifier = Modifier) {
+ Box(modifier.fillMaxWidth().padding(vertical = 6.dp).height(2.dp).bevel(Rk.c, sunken = true, width = 2.dp))
+}
+
+fun Modifier.outline(c: Color) = border(1.dp, c)
+
+fun Modifier.focusableIf(b: Boolean) = if (b) focusable() else this
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt
new file mode 100644
index 0000000..d2ef012
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Theme.kt
@@ -0,0 +1,75 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.ui.res.Res
+import coffee.liz.rookery.ui.res.silkscreen
+import coffee.liz.rookery.ui.res.silkscreen_bold
+import coffee.liz.rookery.ui.res.vt323
+import org.jetbrains.compose.resources.Font
+import androidx.compose.ui.text.font.FontWeight
+
+/** Colour tokens. Two themes: "ice floe" (light) and "polar night" (dark). */
+@Immutable
+data class Palette(
+ val bg: Color, val panel: Color, val bevelHi: Color, val bevelLo: Color, val bevelDark: Color,
+ val ink: Color, val inkDim: Color, val field: Color, val zebra: Color,
+ val titleA: Color, val titleB: Color, val titleInk: Color,
+ val select: Color, val selectInk: Color,
+ val good: Color, val bad: Color, val gold: Color, val lcd: Color, val lcdInk: Color,
+ /** Sticker colours U R F D L B for scramble previews in this orientation. */
+ val dark: Boolean,
+) {
+ companion object {
+ val IceFloe = Palette(
+ bg = Color(0xFFDFE7EC), panel = Color(0xFFC9D3DA), bevelHi = Color(0xFFFFFFFF), bevelLo = Color(0xFF6B7A86), bevelDark = Color(0xFF26313A),
+ ink = Color(0xFF101820), inkDim = Color(0xFF45525D), field = Color(0xFFFFFFFF), zebra = Color(0xFFE8EEF2),
+ titleA = Color(0xFF123A57), titleB = Color(0xFF3F88C5), titleInk = Color.White,
+ select = Color(0xFF1F5F8F), selectInk = Color.White,
+ good = Color(0xFF256B2A), bad = Color(0xFFB3261E), gold = Color(0xFFB07A00),
+ lcd = Color(0xFF9DB89A), lcdInk = Color(0xFF14220F), dark = false,
+ )
+ val PolarNight = Palette(
+ bg = Color(0xFF0C1216), panel = Color(0xFF17212A), bevelHi = Color(0xFF34465A), bevelLo = Color(0xFF070A0D), bevelDark = Color(0xFF000000),
+ ink = Color(0xFFE3ECF1), inkDim = Color(0xFF9AACB8), field = Color(0xFF0A0F13), zebra = Color(0xFF131C23),
+ titleA = Color(0xFF0B2233), titleB = Color(0xFF2B6C9E), titleInk = Color(0xFFE3ECF1),
+ select = Color(0xFF2B6C9E), selectInk = Color.White,
+ good = Color(0xFF7AD67F), bad = Color(0xFFFF8A80), gold = Color(0xFFFFD54F),
+ lcd = Color(0xFF0F1E12), lcdInk = Color(0xFF8CFF9E), dark = true,
+ )
+ }
+}
+
+@Immutable
+data class Type(val pixel: FontFamily, val pixelBold: FontFamily, val lcd: FontFamily) {
+ val heading get() = TextStyle(fontFamily = pixelBold, fontSize = 14.sp, letterSpacing = 0.5.sp)
+ val label get() = TextStyle(fontFamily = pixel, fontSize = 12.sp)
+ val body get() = TextStyle(fontSize = 14.sp)
+ val mono get() = TextStyle(fontFamily = lcd, fontSize = 20.sp)
+}
+
+val LocalPalette = staticCompositionLocalOf { Palette.IceFloe }
+val LocalType = staticCompositionLocalOf<Type> { error("no type") }
+
+object Rk {
+ val c: Palette @Composable get() = LocalPalette.current
+ val t: Type @Composable get() = LocalType.current
+}
+
+@Composable
+fun RookeryTheme(dark: Boolean, content: @Composable () -> Unit) {
+ val type = Type(
+ pixel = FontFamily(Font(Res.font.silkscreen)),
+ pixelBold = FontFamily(Font(Res.font.silkscreen_bold, FontWeight.Bold)),
+ lcd = FontFamily(Font(Res.font.vt323)),
+ )
+ CompositionLocalProvider(LocalPalette provides if (dark) Palette.PolarNight else Palette.IceFloe, LocalType provides type) {
+ content()
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
new file mode 100644
index 0000000..e475436
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
@@ -0,0 +1,323 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.model.formatSolve
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.stats.Averages
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.timer.TimerMachine
+import coffee.liz.rookery.timer.TimerMachine.State
+import org.jetbrains.compose.resources.ExperimentalResourceApi
+import org.jetbrains.compose.resources.decodeToSvgPainter
+
+@Composable
+fun TimerScreen(m: AppModel) {
+ // drive the clock while something is ticking
+ val ticking = m.timer !is State.Idle && m.timer !is State.Stopped
+ LaunchedEffect(ticking) { while (ticking) withFrameMillis { m.frame() } }
+ LaunchedEffect(Unit) { while (true) { kotlinx.coroutines.delay(1000); m.frame() } }
+
+ Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SessionBox(m)
+ SolveTable(m, Modifier.weight(1f))
+ }
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ScrambleBox(m)
+ TimerDisplay(m, Modifier.weight(1f))
+ LastSolveBar(m)
+ }
+ Column(Modifier.width(290.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatsBox(m)
+ PreviewBox(m)
+ SelectedSolveBox(m)
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun SessionBox(m: AppModel) {
+ var choosing by remember { mutableStateOf(false) }
+ GroupBox("Session") {
+ Column(Modifier.heightIn(max = 160.dp)) {
+ LazyColumn {
+ itemsIndexed(m.derived.sessions) { i, s ->
+ val sel = s.id == m.derived.session?.id
+ Row(
+ Modifier.fillMaxWidth().background(if (sel) Rk.c.select else if (i % 2 == 1) Rk.c.zebra else Color.Transparent)
+ .clickable { m.selectSession(s.id) }.padding(horizontal = 6.dp, vertical = 3.dp),
+ ) {
+ Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else null, maxLines = 1)
+ Label(s.event.short, color = if (sel) Rk.c.selectInk else Rk.c.inkDim, style = Rk.t.label)
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ RetroButton(if (choosing) "Cancel" else "New session…", { choosing = !choosing })
+ if (choosing) {
+ Spacer(Modifier.height(6.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (e in Event.entries) RetroButton(e.short, { m.createSession(e); choosing = false }, description = "New ${e.title} session")
+ }
+ }
+ }
+}
+
+@Composable
+private fun SolveTable(m: AppModel, modifier: Modifier) {
+ val solves = m.derived.sessionSolves
+ val results = remember(solves) { solves.map { it.result } }
+ val list = rememberLazyListState()
+ LaunchedEffect(solves.size) { if (solves.isNotEmpty()) list.scrollToItem(0) }
+ Panel(modifier.fillMaxWidth(), padding = androidx.compose.foundation.layout.PaddingValues(2.dp)) {
+ Row(Modifier.fillMaxWidth()) {
+ HeaderCell("#", 0.6f); HeaderCell("time", 1.4f); HeaderCell("ao5", 1.2f); HeaderCell("ao12", 1.2f)
+ }
+ Box(Modifier.fillMaxSize().background(Rk.c.field).bevel(Rk.c, sunken = true)) {
+ LazyColumn(state = list) {
+ val n = solves.size
+ itemsIndexed(solves.asReversed(), key = { _, s -> s.id }) { ri, s ->
+ val i = n - 1 - ri
+ val sel = s.id == m.selectedSolveId
+ val ao5 = if (i >= 4) formatTime(Averages.average(results.subList(i - 4, i + 1)), true) else ""
+ val ao12 = if (i >= 11) formatTime(Averages.average(results.subList(i - 11, i + 1)), true) else ""
+ val ink = if (sel) Rk.c.selectInk else Rk.c.ink
+ Row(
+ Modifier.fillMaxWidth().background(if (sel) Rk.c.select else if (ri % 2 == 1) Rk.c.zebra else Color.Transparent)
+ .clickable { m.selectedSolveId = if (sel) null else s.id }
+ .semantics(mergeDescendants = true) { contentDescription = "solve ${i + 1}, ${formatSolve(s)}" },
+ ) {
+ Cell("${i + 1}", 0.6f, color = if (sel) ink else Rk.c.inkDim)
+ Cell(if (s.penalty == Penalty.DNF) "DNF" else formatSolve(s), 1.4f, color = when { sel -> ink; s.penalty == Penalty.DNF -> Rk.c.bad; else -> ink })
+ Cell(ao5, 1.2f, color = ink)
+ Cell(ao12, 1.2f, color = ink)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScrambleBox(m: AppModel) {
+ Panel(Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Label(m.event.title.uppercase(), Modifier.weight(1f), style = Rk.t.heading)
+ RetroButton("Next ▸", { m.newScramble() }, description = "next scramble")
+ }
+ Spacer(Modifier.height(6.dp))
+ val size = when { m.scramble.length > 300 -> 18.sp; m.scramble.length > 120 -> 22.sp; else -> 30.sp }
+ BasicText(
+ m.scramble, Modifier.fillMaxWidth().background(Rk.c.field).bevel(Rk.c, sunken = true).padding(10.dp)
+ .semantics { contentDescription = "scramble: ${m.scramble}" },
+ style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.15f),
+ )
+ }
+}
+
+@Composable
+private fun TimerDisplay(m: AppModel, modifier: Modifier) {
+ val p = Rk.c
+ val s = m.timer
+ val (text, color) = timerText(s, m.now, m.lastSolve()?.let { formatSolve(it) }, p)
+ val mood = when {
+ s is State.Running -> Mood.SOLVING
+ s is State.Ready -> Mood.READY
+ s is State.Inspecting || s is State.Holding -> Mood.WATCHING
+ s is State.Stopped && m.lastPbs.isNotEmpty() -> Mood.HAPPY
+ s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD
+ m.now - m.lastActivity > 90_000 -> Mood.SLEEPY
+ else -> Mood.IDLE
+ }
+ Column(
+ modifier.fillMaxWidth().background(p.lcd).bevel(p, sunken = true)
+ .pointerInput(Unit) {
+ awaitEachGesture {
+ awaitFirstDown(); m.press()
+ waitForUpOrCancellation(); m.release()
+ }
+ },
+ ) {
+ Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
+ BasicText(
+ text,
+ Modifier.semantics { liveRegion = LiveRegionMode.Polite; contentDescription = "timer $text" },
+ style = Rk.t.mono.copy(fontSize = 150.sp, color = color, textAlign = TextAlign.Center),
+ )
+ }
+ BasicText(
+ hint(s, m.inspection), Modifier.fillMaxWidth().padding(4.dp),
+ style = Rk.t.label.copy(color = p.lcdInk.copy(alpha = 0.7f), textAlign = TextAlign.Center),
+ )
+ MascotStrip(mood, m.reducedMotion, Modifier.fillMaxWidth().height(72.dp))
+ }
+}
+
+private fun timerText(s: State, now: Long, last: String?, p: Palette): Pair<String, Color> = when (s) {
+ State.Idle -> (last ?: "0.00") to p.lcdInk
+ is State.Inspecting -> {
+ val left = 15 - (now - s.since) / 1000
+ (if (left > 0) "$left" else if (left > -2) "+2" else "DNF") to if (left <= 3) p.bad else p.lcdInk
+ }
+ is State.Holding -> (s.inspectingSince?.let { "${(15 - (now - it) / 1000).coerceAtLeast(0)}" } ?: "0.00") to p.bad
+ is State.Ready -> "0.00" to p.good
+ is State.Running -> formatTime(now - s.since).let { if (s.penalty == Penalty.PLUS2) "$it+" else it } to p.lcdInk
+ is State.Stopped -> when (s.penalty) {
+ Penalty.DNF -> "DNF"
+ Penalty.PLUS2 -> formatTime(s.ms + 2000) + "+"
+ Penalty.NONE -> formatTime(s.ms)
+ } to p.lcdInk
+}
+
+private fun hint(s: State, inspection: Boolean) = when (s) {
+ State.Idle, is State.Stopped -> if (inspection) "space / tap: start inspection" else "hold space / tap and hold: get ready"
+ is State.Inspecting -> "hold space to get ready"
+ is State.Holding -> "keep holding…"
+ is State.Ready -> "release to start!"
+ is State.Running -> "any key / tap to stop"
+}
+
+@Composable
+private fun LastSolveBar(m: AppModel) {
+ val last = m.lastSolve() ?: return
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Label("last solve:", style = Rk.t.label)
+ RetroButton("OK", { m.setPenalty(last.id, Penalty.NONE) }, selected = last.penalty == Penalty.NONE, description = "no penalty (ctrl+1)")
+ RetroButton("+2", { m.setPenalty(last.id, Penalty.PLUS2) }, selected = last.penalty == Penalty.PLUS2, description = "plus two (ctrl+2)")
+ RetroButton("DNF", { m.setPenalty(last.id, Penalty.DNF) }, selected = last.penalty == Penalty.DNF, description = "DNF (ctrl+3)")
+ RetroButton("Delete", { m.deleteSolve(last.id) }, description = "delete last solve")
+ Spacer(Modifier.weight(1f))
+ if (m.lastPbs.isNotEmpty()) Label("★ " + m.lastPbs.joinToString { it.kind.label } + " PB", color = Rk.c.gold, style = Rk.t.heading)
+ }
+}
+
+@Composable
+private fun StatsBox(m: AppModel) {
+ val ss = m.derived.sessionStats
+ val es = m.derived.eventStats
+ GroupBox("Stats") {
+ Row { HeaderCell("", 1f); HeaderCell("now", 1.2f); HeaderCell("best", 1.2f); HeaderCell("ever", 1.2f) }
+ for ((i, k) in listOf(StatKind.SINGLE, StatKind.MO3, StatKind.AO5, StatKind.AO12, StatKind.AO50, StatKind.AO100, StatKind.AO1000).withIndex()) {
+ val line = ss.line(k)
+ val life = es.line(k)
+ if (line == null && life == null) continue
+ val round = k != StatKind.SINGLE
+ Row(Modifier.background(if (i % 2 == 1) Rk.c.zebra else Rk.c.field)) {
+ Cell(k.label, 1f, style = Rk.t.label)
+ Cell(formatTime(line?.current, round), 1.2f)
+ Cell(formatTime(line?.best, round), 1.2f)
+ Cell(formatTime(life?.best, round), 1.2f, color = Rk.c.gold.takeIf { life?.best != null && life.best == line?.best && life.best != DNF })
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Label("${ss.solved}/${ss.count} solved · mean ${formatTime(ss.mean, true)}", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+}
+
+@OptIn(ExperimentalResourceApi::class)
+@Composable
+private fun PreviewBox(m: AppModel) {
+ m.replay?.let { ReplayBox(m, it); return }
+ val state = m.previewState
+ val svg = m.previewSvg
+ val density = LocalDensity.current
+ val painter = remember(svg, density) { svg?.let { runCatching { it.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() } }
+ GroupBox("Scramble preview") {
+ val colors = remember(m.event) { Puzzles.colors(m.event.puzzle).map(::rgb) }
+ if (state != null) PuzzleView(state, colors, Modifier.fillMaxWidth().height(200.dp), description = "3D scramble preview, drag to rotate")
+ // tnoodle's official 2D drawing: the reference for every event
+ if (painter != null) Image(painter, "scramble drawing for ${m.event.title}", Modifier.fillMaxWidth().height(if (state != null) 120.dp else 170.dp))
+ }
+}
+
+@Composable
+private fun ReplayBox(m: AppModel, r: Replay) {
+ val camera = remember(r.solveId) { Camera() }
+ GroupBox("Replay") {
+ PuzzleView(r.state, remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }, Modifier.fillMaxWidth().height(240.dp), camera, r.anim, "solve replay, drag to rotate")
+ Label("move ${r.index}/${r.total} · ${formatTime(r.elapsedMs)}", style = Rk.t.mono)
+ when (r.endedSolved) {
+ true -> Label("✓ moves solve the scramble", color = Rk.c.good, style = Rk.t.label)
+ false -> Label("✗ cube not solved at the end", color = Rk.c.bad, style = Rk.t.label)
+ null -> {}
+ }
+ Spacer(Modifier.height(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ m.derived.sessionSolves.firstOrNull { it.id == r.solveId }?.let { s -> RetroButton("↺ Again", { m.startReplay(s) }) }
+ RetroButton("Close", { m.stopReplay() })
+ }
+ }
+}
+
+@Composable
+private fun SelectedSolveBox(m: AppModel) {
+ val s = m.derived.sessionSolves.firstOrNull { it.id == m.selectedSolveId } ?: return
+ GroupBox("Solve") {
+ Label(formatSolve(s), style = Rk.t.mono.copy(fontSize = 36.sp))
+ Label(s.scramble, style = Rk.t.mono.copy(fontSize = 16.sp), color = Rk.c.inkDim)
+ s.moves?.let { mv -> Label("smart-cube reconstruction · ${mv.split(' ').size} moves", style = Rk.t.label, color = Rk.c.inkDim) }
+ if (s.comment.isNotEmpty()) Label("“${s.comment}”")
+ if (s.moves != null && s.event.puzzle == Puzzle.CUBE3) {
+ Spacer(Modifier.height(4.dp))
+ RetroButton("▶ Replay in 3D", { m.startReplay(s) })
+ }
+ Spacer(Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ RetroButton("OK", { m.setPenalty(s.id, Penalty.NONE) }, selected = s.penalty == Penalty.NONE)
+ RetroButton("+2", { m.setPenalty(s.id, Penalty.PLUS2) }, selected = s.penalty == Penalty.PLUS2)
+ RetroButton("DNF", { m.setPenalty(s.id, Penalty.DNF) }, selected = s.penalty == Penalty.DNF)
+ RetroButton("Delete", { m.deleteSolve(s.id) })
+ }
+ }
+}