diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:11:08 -0800 |
| commit | 3afb04de43726d71580b626e856c68b6ed79984e (patch) | |
| tree | 33f7bcb227ff8cfcb046a10036d2faa69c2edd1f | |
| download | rookery-3afb04de43726d71580b626e856c68b6ed79984e.tar.gz rookery-3afb04de43726d71580b626e856c68b6ed79984e.zip | |
Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE
Kotlin Multiplatform + Compose desktop app.
- Sync: per-device append-only op logs (HLC-stamped LWW) in a plain
folder, safe under Syncthing/Drive; content-addressed image blobs.
- Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN,
per-event lifetime PBs with a pixel-penguin party and synthesized
8-bit chime. Mascot sprite from liz.coffee.
- Scrambles for all WCA events + FTO via tnoodle-lib, with previews.
- Importers: csTimer exports (incl. smart-cube reconstructions and
cube MACs) and the Letter Pair Gym prototype (words, notes, images,
history, practice stats). Idempotent; headless `--import`.
- Letter-pair grid with miss heatmap, glance lookup and editor.
- 3D puzzle engine (polyhedron + cut planes) and Canvas renderer:
NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against
tnoodle; 3D replay of smart-cube solves.
- GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth,
MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI.
Not yet tested against hardware.
- lizCI pipeline (.ci) building a test+package Docker target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 = """; + break; + case 39: + escape = "'"; + break; + case 38: + escape = "&"; + break; + case 60: + escape = "<"; + break; + case 62: + escape = ">"; + 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"] @@ -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 Binary files differnew file mode 100644 index 0000000..51cbd2a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar 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 @@ -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 Binary files differnew file mode 100644 index 0000000..24670c1 --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/penguin_sheet.png diff --git a/ui/src/commonMain/composeResources/font/silkscreen.ttf b/ui/src/commonMain/composeResources/font/silkscreen.ttf Binary files differnew file mode 100644 index 0000000..8caff81 --- /dev/null +++ b/ui/src/commonMain/composeResources/font/silkscreen.ttf diff --git a/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf b/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf Binary files differnew file mode 100644 index 0000000..6771252 --- /dev/null +++ b/ui/src/commonMain/composeResources/font/silkscreen_bold.ttf diff --git a/ui/src/commonMain/composeResources/font/vt323.ttf b/ui/src/commonMain/composeResources/font/vt323.ttf Binary files differnew file mode 100644 index 0000000..afa6909 --- /dev/null +++ b/ui/src/commonMain/composeResources/font/vt323.ttf 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) }) + } + } +} |
