From 079319894dccd4cb804bd1487a2db3c3881ce09b Mon Sep 17 00:00:00 2001 From: Lumi Date: Fri, 9 Oct 2026 22:02:06 -0700 Subject: [PATCH] Add Rust pet hosting with reversible local and remote modes --- .gitignore | 4 + AGENTS.md | 39 +- README.md | 21 +- docs/architecture.md | 38 +- docs/assets/cloud-pet-lcd.png | Bin 0 -> 1641 bytes docs/assets/cloud-pet-setup.png | Bin 0 -> 39210 bytes docs/deployment.md | 26 +- docs/export-and-runtime.md | 15 + docs/extending.md | 3 +- docs/gotchas.md | 19 + docs/host-page.md | 27 +- docs/pet-simulation.md | 53 +- docs/prompt-format.md | 3 +- docs/testing.md | 64 +- package.json | 4 +- server/Cargo.lock | 1679 +++++++++++++++++++++++++++++++ server/Cargo.toml | 33 + server/README.md | 153 +++ server/src/bin/brain-probe.rs | 20 + server/src/bin/contract.rs | 34 + server/src/brain.rs | 310 ++++++ server/src/host.rs | 579 +++++++++++ server/src/lib.rs | 6 + server/src/main.rs | 100 ++ server/src/simulation.rs | 830 +++++++++++++++ server/src/store.rs | 253 +++++ server/src/types.rs | 204 ++++ server/src/world.rs | 267 +++++ server/tests/host.rs | 517 ++++++++++ server/tests/parity.mjs | 28 + site/build.sh | 3 + site/index.html | 19 +- site/sw.js | 2 +- test/dev-site.sh | 1 + test/hosting-flow.js | 171 ++++ test/storage.test.mjs | 14 +- web/app/brain.ts | 37 +- web/app/canvas.ts | 4 +- web/app/care.ts | 8 +- web/app/commands.ts | 75 +- web/app/conversation.ts | 25 +- web/app/debug.ts | 2 + web/app/diary.ts | 8 +- web/app/game.ts | 7 + web/app/hosting.ts | 568 +++++++++++ web/app/lifecycle.ts | 31 +- web/app/save-format.ts | 21 +- web/app/simulation.ts | 6 + web/app/storage.ts | 7 + web/app/store.ts | 8 + web/components/Brain.tsx | 31 + web/components/Chat.tsx | 24 +- web/components/Debug.tsx | 14 +- web/components/Device.tsx | 3 +- web/components/Hosting.tsx | 127 +++ web/components/Settings.tsx | 50 +- web/components/Setup.tsx | 96 +- web/types.ts | 34 +- 58 files changed, 6621 insertions(+), 104 deletions(-) create mode 100644 docs/assets/cloud-pet-lcd.png create mode 100644 docs/assets/cloud-pet-setup.png create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/README.md create mode 100644 server/src/bin/brain-probe.rs create mode 100644 server/src/bin/contract.rs create mode 100644 server/src/brain.rs create mode 100644 server/src/host.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/main.rs create mode 100644 server/src/simulation.rs create mode 100644 server/src/store.rs create mode 100644 server/src/types.rs create mode 100644 server/src/world.rs create mode 100644 server/tests/host.rs create mode 100644 server/tests/parity.mjs create mode 100644 test/hosting-flow.js create mode 100644 web/app/hosting.ts create mode 100644 web/components/Hosting.tsx diff --git a/.gitignore b/.gitignore index d1d3f00..19592c6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ logs/ node_modules/ dist/ +server/target/ +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/AGENTS.md b/AGENTS.md index 700dd10..9f41cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,12 @@ # Working on tokenling This document is the maintainer contract for humans and coding agents changing -the repository. tokenling is a browser-only virtual pet whose brain is a full +the repository. tokenling is a virtual pet whose brain is a full fine-tune of GPT-2 (the original 124M "small" model). The page, the pet's -state and the model weights all live in the visitor's browser. No server or -API is in the loop. +state and model weights live in the visitor's browser in local mode. Remote +mode is an explicit choice: the optional Rust host stores private state and +runs the body and brain. Owners can transfer either way without losing the +pet. The browser keeps a recovery checkpoint. ## Start here @@ -13,6 +15,7 @@ Read the area document before editing a subsystem. | Area | Documentation | Primary source | |---|---|---| | System overview, data flow | `docs/architecture.md` | `web/main.tsx`, `web/app/`, `web/brain.ts` | +| Rust host, ownership, private API, native inference | `server/README.md` | `server/src/`, `web/app/hosting.ts` | | Prompt header, tool grammar, lookups | `docs/prompt-format.md` | `train/world.py`, `web/world.ts` | | Pet simulation, storage, UI | `docs/pet-simulation.md` | `web/app/`, `web/components/`, `web/scene.ts`, `web/sprites.ts` | | The page and embedding the app | `docs/host-page.md` | `site/index.html`, `web/components/` | @@ -34,6 +37,11 @@ header text, a word list, a level threshold, an event line or a tool name is a **retraining event**: change both files, regenerate or rewrite the data, train a new model id and publish it. Never "fix" the format in only one place. +`server/src/world.rs` mirrors the same contract for hosted pets. Keep all three +renderers in sync and run `node server/tests/parity.mjs` after building the +Rust contract binary. Moving execution does not authorize a format change or +an implicit brain upgrade. + ### Tags are a closed grammar The pet may only emit the tags in `docs/prompt-format.md`, with values from the @@ -138,15 +146,23 @@ until they accept the upgrade dialog, and anything only newer brains can do is gated by a named feature (`docs/extending.md`). After a prompt-format change, `older` must be empty. -### WebAssembly only, 4-bit packed +### Local WebAssembly and hosted native inference -The brain runs on ONNX Runtime Web's WebAssembly backend. WebGPU was removed +The local brain runs on ONNX Runtime Web's WebAssembly backend. WebGPU was removed in v2: for a model this small, per-token dispatch overhead outweighs the GPU, and one CPU path is simpler to keep correct. The published file stores the body 4-bit and unpacks it to fp32 with constant-foldable ops at load (`train/quantize.py`), because ORT Web's 4-bit `MatMulNBits` kernel is about 5x slower than fp32 in WebAssembly. Keep the tied embedding / LM head at 8 bits. +Hosted inference uses native ONNX Runtime with the same graph and prompt +protocol. Each worker owns one mutable session. The host serializes turns +per pet and commits only matching epochs/context revisions. Prepare inactive +state before remote activation; park the host before local resumption. +Offline recovery is an explicit branch from the last checkpoint, not proof +that an unreachable host stopped. Never silently overwrite newer local +progress with its eventual response. + ### Keep it cute Pet text is filtered through `NOT_CUTE` in `web/app/text.ts` before display, because @@ -191,6 +207,12 @@ measured: mark anything untested as untested, and put estimates and choices made by reasoning under that label, as `docs/training.md` does for the two-instance teacher setup. +For host changes, also run `cargo fmt --check --manifest-path server/Cargo.toml`, +`cargo test --locked --manifest-path server/Cargo.toml`, build the +`tokenling-contract` binary and run `node server/tests/parity.mjs`. Exercise +both transfer directions and lost responses with the real CPU brain; record +CUDA as untested until the native CUDA worker has been checked separately. + **Keep these docs host-neutral.** They describe the project, not any particular machine or deployment: no host names, domains, hardware inventories beyond the GPU model used, service names, absolute paths on a specific machine, @@ -240,8 +262,11 @@ must build and deploy on its own, with no host-specific tooling. as soon as the GPU work is done (`docs/deployment.md`). - Keep the page's explanation and build details readable without JavaScript; the interactive app is hidden until the module runs. -- Do not store anything about the pet or its owner outside the visitor's - browser. The optional, deployment-configured Cloudflare Web Analytics +- Local mode never sends or stores pet/owner data outside the visitor's + browser. Explicitly selected remote mode sends private data only to the + chosen host. Keep access keys out of exports, logs, analytics and Git; + protect the host's private SQLite state and hash its access keys. + The optional, deployment-configured Cloudflare Web Analytics beacon may report page traffic and performance only; never include pet or owner fields, conversations, diaries, saves or gameplay events. Keep its site token in deployment configuration and disclose analytics on the page. diff --git a/README.md b/README.md index cc1a465..159b5dd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tokenling -**tokenling**, a GPT-2 pet that runs entirely in your browser. +**tokenling**, a GPT-2 pet that can live on your device or a trusted server. A small Tamagotchi-like creature whose brain is a fully fine-tuned [GPT-2](https://huggingface.co/openai-community/gpt2) (the original 124M @@ -10,8 +10,8 @@ catch you offer, wants things, naps and dreams, remembers things about you and w diary page at bedtime. The page only runs its body (hunger, energy, mess) and its room. It grows from baby to kid, teen and adult in real time. -There's no server or API: the egg hatches once the page has downloaded the -brain (about 97 MB, 4-bit) into the browser's Cache Storage, and it runs on the +In local mode, there's no server or API: the egg hatches once the page has +downloaded the brain (about 97 MB, 4-bit) into the browser's Cache Storage, and it runs on the visitor's device with transformers.js on WebAssembly. The pet, its chat, memories, gifts and diary live in `localStorage`. It installs as an app, works offline, and can send alerts the owner turns on (no push server: the page @@ -19,6 +19,14 @@ plans them and its service worker shows them). New brains are offered as an upgrade; compatible older brains can stay in use. After a prompt-format change the pet dozes until its new brain is downloaded, keeping its save. +Choose local or remote when you get an egg, and switch either way later in +settings. The optional [Rust host](server/README.md) stores a remote pet’s +private save and runs its body and brain while the browser is closed. The +browser keeps a recovery checkpoint. Transfers preserve memories, gifts, +chat, diary and birth time. Remote mode shares that data with the chosen +host, so choose one you trust. Private access keys come first; ATProto +sign-in and multiplayer are later work. + ## The pet's tools GPT-2 was fine-tuned to call tools inline in its own lines. The app parses @@ -62,6 +70,7 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] |---|---| | [`AGENTS.md`](AGENTS.md) | Maintainer contract for humans and coding agents: invariants, rules, commands. Start here before changing anything. | | [`docs/architecture.md`](docs/architecture.md) | Modules, the pet's turn, where the model comes from | +| [`server/README.md`](server/README.md) | Rust host setup, private API, transfers, workers and server checks | | [`docs/prompt-format.md`](docs/prompt-format.md) | The header, event lines, tool grammar and lookups; the contract with the weights | | [`docs/pet-simulation.md`](docs/pet-simulation.md) | Life cycle, stats, care actions, minigame, diary, storage schema, LCD | | [`docs/training.md`](docs/training.md) | Teacher data, validation, fine-tuning, evals, the run receipts | @@ -78,6 +87,8 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] |---|---| | `web/main.tsx`, `web/components/` | Preact entry and typed UI components: device/setup, chat, diary, shelf, settings, brain notice/dialog and debug panel | | `web/app/` | typed state, save format/storage, body simulation, care, animations, conversation, diary, model client, game, PWA and lifecycle controllers; see the module map in `docs/architecture.md` | +| `web/app/hosting.ts`, `web/components/Hosting.tsx` | authenticated remote sync, both transfer directions, local recovery and the settings choice | +| `server/` | Rust/Axum/SQLite host, native ONNX workers, simulation/prompt mirrors and ownership/API/parity tests | | `web/types.ts`, `web/runtime-types.ts` | save, scene and worker message types; the vendored runtime API boundary | | `web/brain.ts` | model worker; bundled separately as `brain.js` | | `web/scene.ts`, `web/sprites.ts`, `web/world.ts` | pixel LCD, sprite icons and the Python-compatible prompt format | @@ -101,12 +112,14 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] | `test/contract.mjs` | checks the Preact component ids, host mount/config contract and docs | | `test/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | body rules and save compatibility, including a save captured from the previous app | | `test/browser-flow.js` | repeatable playwright-cli browser regression flow against a local debug preview | +| `test/hosting-flow.js` | real host creation, lost response, closed-page execution, both transfers and offline recovery | | `test/dev-site.sh`, `test/serve.py` | local preview: builds the site, stages the runtime and models and serves them with COOP/COEP headers | | `test/bench.html` | brain load time, speed and memory in a real browser (included as unlinked `bench.html` in every site build) | The frontend uses Preact and strict TypeScript. `site/build.sh` type-checks, bundles the app and worker with esbuild, then fills the static host template. -No server is needed at runtime. See [`docs/deployment.md`](docs/deployment.md). +Local mode needs no server at runtime; remote mode uses the optional Rust host. +See [`docs/deployment.md`](docs/deployment.md) and [`server/README.md`](server/README.md). ## Quick start diff --git a/docs/architecture.md b/docs/architecture.md index 3ab1a9e..b82aa41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,14 @@ # Architecture -tokenling is a static site plus a model. Everything runs in the visitor's -browser; the server only hands out files. +tokenling is a static site plus a model, with an optional Rust pet host. In +local mode the browser owns state, simulation and inference. Remote mode +explicitly transfers ownership to the chosen host; the browser becomes a +renderer/controller with a recovery checkpoint. See [the Rust host](../server/README.md). A deployment may enable a separate Cloudflare Web Analytics beacon for page traffic and performance. The builder adds it and its disclosure only when a -site token is configured; pet state and generation remain in the browser. +site token is configured; pet/owner data never enter analytics. Local state +and generation remain in the browser; remote data go only to the chosen host. See `deployment.md` for configuration and privacy checks. ``` @@ -38,9 +41,12 @@ browser | `web/components/` | `App.tsx` owns tabs; `Device.tsx`/`Setup.tsx` own the LCD, care and setup; `Chat.tsx`, `Diary.tsx`, `Shelf.tsx`, `Settings.tsx`, `Brain.tsx` and `Debug.tsx` render the panels and dialogs. `shared.tsx` supplies the revision hook and sprite component. | | `web/types.ts`, `web/runtime-types.ts` | State, scene, worker request/response types and the small API surface used from the separately vendored transformers.js. | | `web/app/store.ts` | Mutable body/brain/view state and transient UI state. `emit()` increments a revision; Preact subscribes. Streaming text is data, never detached DOM nodes. Animation frames do not rerender the UI. | -| `web/app/save-format.ts`, `web/app/storage.ts` | Save v1 defaults/validation/migrations and guarded debounced browser persistence. | +| `web/app/save-format.ts`, `web/app/storage.ts` | Save v2 defaults/validation/migrations and guarded debounced browser persistence. | | `web/app/body.ts`, `web/app/simulation.ts` | Pure body simulation over a supplied state, then the clock, catch-up, growth, day log and prompt state adapter. Alert projection simulates a clone. | | `web/app/conversation.ts`, `web/app/text.ts` | React/idle turn scheduler, lookup continuations, stale replies and tag effects; text filtering and chat chip descriptions. | +| `web/app/hosting.ts`, `web/components/Hosting.tsx` | Authenticated sync, staged local/remote transfers, stale-response rejection, recovery and mode controls. | +| `server/src/` | Rust private API, SQLite ownership/receipts, background scheduler, body/prompt mirrors and native ONNX inference. | +| `server/tests/`, `server/src/bin/` | ownership/API/body tests, Rust parity and native inference probe. | | `web/app/care.ts`, `web/app/diary.ts` | Hatching, care offers/answers and bedtime/missed diary pages. | | `web/app/animation.ts`, `web/app/canvas.ts`, `web/app/game.ts` | Animation state, disturbances, canvas attachment/cleanup and catch physics/input. | | `web/app/brain.ts` | Model cache, download, upgrade, worker protocol and one-thread fallback. | @@ -53,6 +59,7 @@ browser | `site/bundle.mjs`, `package.json`, `package-lock.json`, `tsconfig.json` | Pinned Preact/TypeScript/esbuild build, strict checking and Prettier formatting. | | `test/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | Body rules and compatibility with a real previous-app save. | | `test/browser-flow.js` | Repeatable playwright-cli flow for a local debug preview with a real brain. | +| `test/hosting-flow.js` | Real remote creation, lost activation acknowledgement, closed-page execution, transfers and checkpoint recovery. | | `train/migrate_v6.py` | Rewrites old chat headers and sleep events, rebuilds loss spans, removes old teen rows and validates the combined dataset. | | `test/data_rules.py` | Regression cases for sleep, offers, validation, loss masks and migration; includes a stand-in sleep teacher. | | `test/site_tools.py` | Stdlib-only regressions that exercise the real build, publish and preview scripts with disposable model/runtime stand-ins. | @@ -70,6 +77,27 @@ inputs, tab selection and refs. The canvas effect cancels its animation frame and removes input listeners when it unmounts (including tab takeover). The host contract is in `host-page.md`. +## Execution ownership + +Setup asks local or remote, defaulting to local. Existing v1 saves migrate to +local v2 saves without changing pet data. Settings permits both directions. +`hosting` pins mode, selected brain and time zone; a separate browser record +holds remote access keys and transfer intent. Exports omit keys and restore +as local recovery saves. + +Local-to-remote prepares an inactive record before activation and records +remote intent before it gives up local execution. Remote-to-local parks the +host atomically, fetches the latest full state and resets the browser body +clock. Epoch/context checks fence late replies; operation IDs deduplicate +retries. An explicit outage recovery uses the last checkpoint and asks the +old host to stop when reachable, without overwriting the new local branch. + +Remote mode stops browser body/brain scheduling. Rust owns simulation, +lookups, tags, memories and diary; returned effects only animate the LCD. +The host thinks while the page is closed. Its native CPU worker is resident; +a separately configured CUDA worker can handle reactive turns. Full serving +and GPU performance qualification are separate from prior engine probes. + ## The pet's turn 1. Something asks for a turn (`wantTurn`). **React** turns follow an owner @@ -129,7 +157,7 @@ See `training.md` and `export-and-runtime.md`. - **GPT-2 small is small, on purpose.** Behavior is taught by example; it cannot follow instructions, and its occasional silliness is part of the pet. Everything it should "know" is in the prompt format. -- **Phones.** A 97 MB download, WebAssembly only, about 0.8 GB of page memory +- **Phones in local mode.** A 97 MB download, WebAssembly only, about 0.8 GB of page memory once loaded (measured on desktop Chromium; phones not measured). - **Storage.** localStorage is about 5 MB, enough for state; the model goes in Cache Storage, and the page asks for persistent storage so it is not evicted. diff --git a/docs/assets/cloud-pet-lcd.png b/docs/assets/cloud-pet-lcd.png new file mode 100644 index 0000000000000000000000000000000000000000..ddc236621066504147fae81c3a94597d63afa661 GIT binary patch literal 1641 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
dQo;}{Xs^>4aY8qZ08VB^BSe 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 IbxsLQ01uAvMgRZ+ literal 0 HcmV?d00001 diff --git a/docs/deployment.md b/docs/deployment.md index dd70fbb..4ee4281 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,8 +1,8 @@ # Publishing -The app is plain static files. There is no server component, database or -account system. This page covers what to publish, what the web server must do, -and how to ship and roll back a model. For the markup the app expects, see +The local app is plain static files. Optional remote mode adds the Rust host +and its private database; there is no account system yet. This page covers +what to publish, what the web server must do, and how to ship and roll back a model. For the markup the app expects, see `host-page.md`. ## Frontend build prerequisites @@ -194,6 +194,26 @@ Training and bulk teacher generation need the GPUs. On a shared machine: request. Do not hold the GPUs while only CPU work remains. 6. Record the dates, the downtime and the results in your run receipt. +## Optional remote pet host + +The static app supports an independent [Rust host](../server/README.md). +`REMOTE_ENDPOINT` optionally pre-fills its address; otherwise owners enter +one. It never changes the local default. The equivalent release override +key is `remoteEndpoint`. Do not embed private access keys in deployment +configuration or the host page. + +The host has required listen, database, model/release, native runtime and +allowed-origin inputs documented in `server/README.md`. Keep private SQLite +state and backups outside Git. Use HTTPS for remote clients and exact CORS +origins. Authenticated API data use `no-store`; the service worker bypasses +Authorization requests and `/api/` paths. Alerts remain browser/device managed; +there is no new push service. + +Run the Rust ownership/API tests and prompt parity, then check setup choices, +both transfer directions, closed-page background turns, reconnect/outage +recovery and the 360 px layout. A working native CPU prototype does not +qualify GPU integration, mixed concurrency or a public-host deployment. + ## Live checks ```sh diff --git a/docs/export-and-runtime.md b/docs/export-and-runtime.md index 94e3e22..bb22589 100644 --- a/docs/export-and-runtime.md +++ b/docs/export-and-runtime.md @@ -82,6 +82,21 @@ ONNX export on the same rows (+2.0%). Numbers for the shipped model are in change to `quantize.py` to catch degenerate output, and `quant_eval.py` for held-out loss. Both set `disable_quant_qdq` like the browser. +## Optional native hosted runtime + +The Rust host loads the same exported q4 graph with native ONNX Runtime and +a native tokenizer. It preserves the QDQ/prepacking settings, empty per-call +KV, context fit, sampling and line/lookup stop rules. One mutable session +belongs to each serialized worker. Local mode remains WebAssembly only. +Remote mode does not download a browser model; returning local loads the +pinned compatible brain from cache or downloads it. The server cannot silently +upgrade the selected brain. + +See [server setup and library inputs](../server/README.md). Native CPU +correctness and browser transfers are checked separately from speed probes. +The optional native CUDA path is not the prior PyTorch benchmark and has not +been performance-qualified. Published model/runtime files remain immutable. + ## Browser runtime transformers.js 4.3.1 with ONNX Runtime Web 1.31.0-dev.20260914-8d85527a0, diff --git a/docs/extending.md b/docs/extending.md index 3c03751..8533e2f 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -8,7 +8,8 @@ After any change that touches `world.py`/`world.ts`, run `node test/parity.mjs`. ## Retrain loop -1. Update `train/world.py` and `web/world.ts` together. +1. Update `train/world.py`, `web/world.ts` and `server/src/world.rs` together. + Build `tokenling-contract` and run `node server/tests/parity.mjs` too. 2. Update the teacher prompt in `train/gen_data.py` (it builds word lists from `world.py`, but scenarios and examples are written out) and the validator so it accepts exactly the new grammar. diff --git a/docs/gotchas.md b/docs/gotchas.md index 407843d..c1c7d42 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -296,3 +296,22 @@ The old document's Web Lock can briefly outlive navigation. A single immediate the held promise on `pagehide` and retry acquisition briefly without stealing (`web/app/lifecycle.ts`). In browser tests, wait for the closing tab's lock to leave `navigator.locks.query()` before asserting ownership in another tab. + +## A timed-out transfer can still have committed + +Persist remote intent before sending activation, then reconcile it with the +private host snapshot after a reload or lost response. Switching back local +must park even an inactive prepared record and advance its epoch: otherwise +an older activation still in flight can start a second executor. Fence local +replies and browser poll responses as well as server generations. + +Offline recovery needs its own durable pending-stop receipt. Keep that receipt +and key across a move to another host, and never adopt its eventual response +over progress made on the recovery branch. + +## Native ORT errors can prevent a background worker from compiling + +The session builder's recoverable error includes builder state that is not +`Send`. Convert that boundary error to owned text before returning it through +the worker's `anyhow::Result`; model execution still runs behind one session +mutex per worker. This changes error transport, not runtime configuration. diff --git a/docs/host-page.md b/docs/host-page.md index 5f2ab7e..d61382c 100644 --- a/docs/host-page.md +++ b/docs/host-page.md @@ -53,12 +53,28 @@ URL and the worker's scope is the page's directory. | `@ANALYTICS_NOTICE@` | Visible traffic/performance analytics disclosure; emitted with the snippet | | `@NEWS@` | JSON string: what the current brain does better, for the upgrade dialog | | `@FEATURES@` | JSON list of feature names the current brain was trained for | +| `@REMOTE_ENDPOINT@` | JSON string for an optional default remote-host address; empty by default | | `@OLDER@` | JSON list of older brains the app may keep running, `{"id", "features"}`, newest first | `test/dev-site.sh` fills them for local previews and fails if any are left. `node test/contract.mjs` fails if the app starts using an element id or config key that the page or this document does not cover. +## Local and remote choice + +`Setup.tsx` asks local or remote before creating an egg. `Hosting.tsx` keeps +both options in settings, with a trusted server address, transfer status, +reconnect and explicit local-checkpoint recovery. Remote mode explains that +the selected host receives saves, chats and memories. Local mode retains +its original offline/download behavior. The new `hosting-choices` and +`hosting-option` classes style accessible radio groups and their explanations; +URL inputs fit the phone width. Interactive markup stays in Preact. + +The brain notice/status distinguishes browser download from server connection. +Remote care/chat call the private host, and debug body edits are local-only. +Save exports omit access keys. Static explanatory text describes both modes +without requiring JavaScript. + ## Loading ```html @@ -89,6 +105,7 @@ key that the page or this document does not cover. | `sw` | optional URL of the service worker, relative to the page (`sw.js`); without it there is no offline copy and no alerts | | `icon` | optional URL of the icon shown on alerts | | `weatherSeed` | optional string mixed into the deterministic weather | +| `remoteEndpoint` | optional default remote-host address for setup/settings; local remains the default, and owners can supply their own trusted host | `main.js` and `brain.js` must be served together under the hashed app directory. The main bundle includes Preact, all components/controllers, the renderer and @@ -111,7 +128,8 @@ none }` winning over grid/flex rules. | Area | Ids | |---|---| | Screen | `pet-lcd` (a ``), `pet-status`, `pet-meters`, `pet-actions`, `food-menu`, `game-pad`, `pad-left`, `pad-right` | -| Setup | `pet-setup`, `setup-owner` (form; submitting it starts the brain download), `owner-name`, `setup-size` (the download size note), `setup-hatch` (form), `setup-hatch-note` (download progress), `setup-retry` (button, visible after a brain load error), `pet-name`, `hatch-btn` | +| Setup | `pet-setup`, `setup-owner` (form; submitting starts a local brain download or remote connection), `owner-name`, `setup-size` (the download size note), `setup-hatch` (form), `setup-hatch-note` (download progress), `setup-retry` (button, visible after a brain load error), `pet-name`, `hatch-btn` | +| Hosting choice | `setup-local`, `setup-remote`, `setup-server` (initial mode/address); `hosting-title`, `hosting-local`, `hosting-remote`, `hosting-server`, `hosting-status`, `hosting-switch`, `hosting-reconnect`, `hosting-recover` (settings and outage recovery) | | Bad egg | `bad-egg` (inside `pet-setup`, shown instead of the forms when the save cannot be read), `bad-egg-why`, `bad-egg-save`, `bad-egg-retry`, `bad-egg-new` | | Another tab | `pet-elsewhere` (outside the app area; shown when another tab has the pet), `elsewhere-here` | | Chat | `chat-log` (a list), `chat-form`, `chat-input`, `chat-send`, `pet-tip`, `pet-tip-text`, `tip-yes`, `tip-no` (a one-time suggestion: install, then alerts) | @@ -194,9 +212,10 @@ are in `deployment.md`. ## Explaining it to visitors -The shipped page explains that the pet's decisions come from its brain, that -the pet and its brain are stored in the visitor's browser, that the first -download is about 97 MB and that pet data stays in the browser. Keep that on +The shipped page explains that decisions come from the brain. Local mode +stores the pet and brain in the visitor's browser; its first download is about +97 MB. Explicitly selected remote mode sends private saves, conversations and +diaries to the chosen host. Keep that distinction on any page that embeds the app, readable without JavaScript. When page analytics is configured, disclose its traffic/performance reporting separately; do not claim that no information is sent. The beacon receives no pet names, diff --git a/docs/pet-simulation.md b/docs/pet-simulation.md index f3178a3..6a71a58 100644 --- a/docs/pet-simulation.md +++ b/docs/pet-simulation.md @@ -4,7 +4,9 @@ The typed controllers live in `web/app/`; body math is in `body.ts`, the clock and logs in `simulation.ts`, and care/turns/diary/game in their own modules. Preact components in `web/components/` render the UI. `web/scene.ts` and `web/sprites.ts` draw the LCD. Times are real wall-clock time; the pet keeps -aging while the tab is closed. +aging while the tab is closed. In remote mode, `server/src/simulation.rs` +runs the same body and the Rust host also generates thoughts while the page +is closed; the browser renders synchronized state. The split is strict: **the brain decides, the app runs the body.** Stats, poop, sickness, collapsing from exhaustion and waking up rested are @@ -13,6 +15,31 @@ things and choosing to nap come from the model's tags (`prompt-format.md`). Without the brain loaded the pet dozes: the LCD shows it asleep, the care buttons and chat are off, and its body keeps ticking. +## Choosing and changing its home + +Setup asks local or remote, defaulting to local. Local downloads the browser +brain and works offline; remote connects to a chosen, trusted Rust host and +shares the save, conversations and memories with it. Remote eggs hatch only +when the server’s native brain is ready, without a browser model download. + +Settings always exposes both choices. Local-to-remote prepares inactive +server state and persists transfer intent before activation. Remote-to-local +parks the host and receives the latest full save before starting the browser +brain; the browser clock is reset to avoid replaying remote time. Names, +traits, birth time, memories, shelf and diary are preserved. The selected +brain is pinned; a host with another release is rejected without upgrading. +Mode changes wait for an active catch game to finish. + +Foreground remote sync polls once per second and keeps a browser checkpoint. +Late or older responses cannot replace a newer snapshot or local branch. +Owners can reconnect, or explicitly recover locally from the last checkpoint +during an outage. Server-only changes may be missing, and the old executor +may continue until its pending stop is delivered. The browser retains pending +stop keys separately in `tokenling.remote-stops.v1`, including after moving +to another host, and retries every 30 seconds while its owning tab is open, +on reload and on reconnect. The stop receipt never overwrites newer +local progress. See [the ownership protocol](../server/README.md). + ## Life cycle | Stage | Reached at | Look | @@ -171,10 +198,15 @@ the `now:` line and diary headers agree. ## Storage localStorage key `tokenling.v1`, JSON. Saves are debounced by 200 ms and -failures (private mode, quota) are ignored. +failures (private mode, quota) are ignored for ordinary local ticks. Transfers +use a confirmed immediate write before relinquishing local execution. Remote +mode stores a synchronized checkpoint here, while its authoritative state +lives in the host’s private SQLite database. Access keys, connection IDs and +transfer intent use the separate `tokenling.remote.v1` browser key and are +excluded from exported local recovery saves. ``` -v: 1 save version (VERSION in web/app/save-format.ts) +v: 2 save version (VERSION in web/app/save-format.ts) created, lastTick, lastActive, grimeSince, nextPoop ms timestamps owner name the pet calls them egg: { laid } | null @@ -194,6 +226,7 @@ settings: { chatter, alerts, alertsAsked, installAsked } chatter = "lively" idle turns; alerts on (off by default); whether the one-time alerts and install suggestions were answered counts: { fed, pats, games, baths } +hosting: { mode: "local" | "remote", brainId: string | null, timeZone: string } ``` Because `chat[]` stores exactly what the model sees, an export is a faithful @@ -216,7 +249,13 @@ egg. While the bad egg is shown nothing is saved. Imports go through the same `parseSave()`, so an imported older save is migrated and a newer one is refused with the reason. An accepted import writes the validated save and reloads under the Web Lock, cancelling the previous pet's worker, timers and -queued turns before resuming. Save version 1 is unchanged by the Preact rewrite. +queued turns before resuming. The historical Preact rewrite kept v1. Save v2 +adds hosting metadata; v1 migrates to local mode with the original pet data intact. Invalid hosting +metadata/time zones and newer saves are rejected. Exports are local recovery +saves; an import starts locally and does not copy a server access key. +Importing over a remote pet first parks it and saves its latest local copy. +A failed import write therefore retains the previous pet rather than deleting +its only authoritative save. The parked host record remains private. ### One tab at a time @@ -239,6 +278,8 @@ stops the brain and reloads (writing a fresh state through the debounced `save()` lost the race with the reload). The downloaded brain stays in Cache Storage, so a new egg hatches without downloading it again. Deleting the brain clears only Cache Storage, never the pet. +In remote mode, start over also deletes the authenticated host record before +clearing the browser save; a failed host deletion keeps the current pet. ## Installing, offline use and alerts @@ -266,8 +307,8 @@ and talked. **Alerts** are off by default. The settings checkbox asks for notification permission; once the app runs installed, a one-time suggestion offers them. -There is no server and so no push. Instead the page plans: whenever it hides -or shows (and on `pagehide`), it simulates a copy of the pet's body forward in +There is no push service, including in remote mode. Instead the page plans: +whenever it hides or shows (and on `pagehide`), it simulates a copy of the pet's body forward in 10-minute steps for up to a day and writes the first time it will get hungry (tummy below 20, if it is not already), poop, get sick (if it is not already) or reach a new stage, as `tokenling-alerts.json` in the `tokenling-alerts` diff --git a/docs/prompt-format.md b/docs/prompt-format.md index aba265a..8fc692c 100644 --- a/docs/prompt-format.md +++ b/docs/prompt-format.md @@ -1,7 +1,8 @@ # Prompt format and tool grammar This is the contract between the app and the weights. `train/world.py` renders -it for training and `web/world.ts` for the browser; `node test/parity.mjs` +it for training, `web/world.ts` for the browser, and `server/src/world.rs` for +the optional Rust host; `node test/parity.mjs` compares 300 random states byte for byte, checks that the word lists and tool sets are identical, and checks that every event line the app writes matches exactly one of the validator's patterns (and that offers are recognised as diff --git a/docs/testing.md b/docs/testing.md index 4606298..df35da6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,8 +3,10 @@ | Check | Command | Catches | |---|---|---| | Type checking | `npm run typecheck` | strict checking of every TS/TSX component, controller, canvas renderer and worker | -| Body and saves | `node --test test/*.test.mjs` | simulation rules, save v1 compatibility, bad eggs and ownership/reset write guards | +| Body and saves | `node --test test/*.test.mjs` | simulation rules, v1-to-v2 local migration, bad eggs and ownership/reset write guards | | Prompt parity | `node test/parity.mjs` | JS/Python drift in headers, stat words, word lists, look/clock/recall, event lines and offers | +| Rust host | `cargo test --locked --manifest-path server/Cargo.toml` | private access, background turns, both transfers, stale replies, deduplication, persistence, body and sleeping offers | +| Rust prompt parity | build `tokenling-contract`, then `node server/tests/parity.mjs` | Rust/Python headers, lookups, 15 lists and wake probabilities across 300 states | | Model behavior | `train/eval_pet.py CKPT 40` | invalid tags, unresolved lookups, unanswered offers (including after an opener that skipped the answer), decisions that ignore the tummy, chatty idle turns, diaries that do not end | | Quantization | `train/quant_eval.py CKPT EXPORT data.jsonl` | 4-bit quality loss | | Data rules | `python3 test/data_rules.py` | sleep/offer rules, corrupt or shifted supervision, equivalent span coverage, diary masks, no-wake/mixed migration and a stand-in sleep teacher | @@ -38,10 +40,29 @@ independent of model sampling. Use only a test profile: it creates a pet and changes its state. Error recovery, imports, offline use and upgrades have additional recipes below. +For the optional Rust host, start it with the configuration in +[its guide](../server/README.md), allow the preview's browser origin and set +`REMOTE_ENDPOINT` when building the preview. Use another fresh disposable profile: + +```sh +playwright-cli -s=host open http://127.0.0.1:8765/pet/?debug=1 --profile=/tmp/host-test-profile +playwright-cli -s=host run-code --filename=test/hosting-flow.js +``` + +This flow deliberately loses an activation acknowledgement, checks remote +creation without a local model download, closes the app page for 20 seconds, +transfers in both directions with the real brains, and recovers during a +simulated outage. On loopback it uses a second hostname as a stand-in for +another host, verifying that the old pending stop/key survives the move. +It checks 1280/768/360 px and preserves newer recovery-branch state when the +stop receipt arrives. Injected network failures produce expected browser +resource errors. Test profiles contain private keys: keep them outside Git. + `test/fixtures/save-v1.json` is an actual browser export from the previous vanilla app at commit `6c99c23`, captured with generic Sam/Mochi names on 2026-10-07. The save tests -require it to round-trip without changing any values; this rewrite keeps v1. +require the current v2 migration to preserve every original pet value while +adding local hosting metadata. The earlier Preact rewrite kept v1. `test/site_tools.py` uses Python's standard library and the actual shell scripts with disposable model/runtime stand-ins. It needs no downloaded @@ -100,6 +121,40 @@ headless desktop Chromium against a local build with the real v6 brain. Firefox, offline reload, upgrades, alerts and the watchdog stall fallback were not re-tested for this merge. +## Optional Rust host check receipt (2026-10-09) + +Measured in headless desktop Chromium against a local preview with the real +v6 q4 export. Native CPU used eight threads; the browser used its normal WASM +loader. These are functional checks, not new speed or capacity measurements. + +- `npm test` and formatting passed: strict types, 300 JS/Python states and + 15 lists with zero mismatches, page contract, eight body/save tests, + 20 data-rule tests and four script lifecycle tests. Root and subpath builds + passed. The real v1 fixture migrated to v2 without losing original values. +- Seven Rust tests passed: private access, restart persistence, deduplicated + care, complete-save handoffs, old generation/prepared activation fencing, + sleeping offers/body rules, memory eviction and scripted GPU failure + fallback. Rust/Python parity also had 300 states, 15 lists and zero mismatches. +- `test/browser-flow.js` passed with real local generation, care, diary, + catch controls, cached reload and tab takeover. No unexpected browser + console errors or warnings appeared in the regular flow. +- `test/hosting-flow.js` passed: remote creation without a local model + download; a committed activation whose response was deliberately lost; + body progression with the app page closed; both transfers retaining + name, owner, traits and birth time; offline checkpoint recovery; moving to + another host while the previous host was unreachable; eventual stop without + overwriting newer memories. The alternate host used a loopback hostname + alias and a separate hosted record, rather than another physical machine. +- A separate real native-brain check produced autonomous turns with the + page closed, including walking, emotes, sleep and a dream. API tests use + scripted generators to establish timing and fencing deterministically. +- All panels and hosting controls fit 1280/768/360 px without horizontal + overflow. [Setup](assets/cloud-pet-setup.png) and + [LCD](assets/cloud-pet-lcd.png) screenshots were inspected. +- Not measured: physical phones, native CUDA performance/integration, + simultaneous CPU/GPU capacity, public-host deployment, ATProto sign-in or + multiplayer. No production model/runtime artifact was changed. + ## Preview server ```sh @@ -118,8 +173,9 @@ leaves the staged runtime and models alone. Options are environment variables, documented in the script header: `PORT`, `BASE` (the URL base path, default `pet`), `MODEL_ID`, `OLDER` (export directories staged as older brains), `NEWS`, `FEATURES`, and `TRAINED` and -`SOURCE` (text for the page's details). `ISOLATE=0` serves without the -headers for A/B tests. `ONNX_BYTES_PER_SECOND` (default `0`, unlimited) +`SOURCE` (text for the page's details). `REMOTE_ENDPOINT` supplies the optional +default host address. `ISOLATE=0` serves without the headers for A/B tests. +`ONNX_BYTES_PER_SECOND` (default `0`, unlimited) throttles only `.onnx` responses, including URLs with a query string, for slow-download checks; other files retain normal speed. diff --git a/package.json b/package.json index eb244a1..a7dc956 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "typecheck": "tsc --noEmit", "build": "site/build.sh dist", "test": "npm run typecheck && node test/parity.mjs && node test/contract.mjs && node --test test/*.test.mjs && python3 test/data_rules.py && python3 test/site_tools.py", - "format": "prettier --write \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js package.json tsconfig.json", - "format:check": "prettier --check \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js package.json tsconfig.json" + "format": "prettier --write \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js test/hosting-flow.js server/tests/parity.mjs package.json tsconfig.json", + "format:check": "prettier --check \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js test/hosting-flow.js server/tests/parity.mjs package.json tsconfig.json" }, "dependencies": { "preact": "11.0.0" diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..bb1f797 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,1679 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "libloading", + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64", + "nom", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokenling-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "chrono-tz", + "ort", + "rand", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "tokenizers", + "tokio", + "tower", + "tower-http", + "uuid", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..60600a3 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "tokenling-server" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT" +description = "Self-hostable tokenling pet host with native ONNX inference" + +[dependencies] +anyhow = "1" +axum = "0.8.9" +chrono = "0.4" +chrono-tz = "0.10.4" +ort = { version = "=2.0.0-rc.13", default-features = false, features = ["std", "load-dynamic", "api-27", "cuda"] } +rand = "0.9" +regex = "1" +rusqlite = { version = "0.40.2", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +subtle = "2" +tokenizers = { version = "=0.22.2", default-features = false, features = ["fancy-regex"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time", "signal"] } +tower-http = { version = "0.6.11", features = ["cors", "set-header"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } + +[[bin]] +name = "tokenling-contract" +path = "src/bin/contract.rs" diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..beaf733 --- /dev/null +++ b/server/README.md @@ -0,0 +1,153 @@ +# tokenling Rust host + +This optional host runs a pet while its browser is closed. Axum provides the +private HTTP API, SQLite keeps the complete save, and native ONNX Runtime +runs the existing exported brain. The browser renders the room and catch +game. Local mode remains a standalone offline app. Owners choose local or +remote at setup and can transfer either way in settings. + +Remote mode sends the save, including chat, memories and diary, to the chosen +host. Choose a trusted host. Each pet has a random 256-bit access key kept in +its browser; the server stores only a SHA-256 hash. Every read, command, +transfer and deletion requires that key. There is no public pet listing. +Saves, prompts, generated text and keys are not logged. ATProto sign-in and +multiplayer are later work. + +## Build and configuration + +Use Rust 1.88+ and the Cargo lockfile. Supply a compatible native ONNX Runtime +library separately: the binding requests API 27 (1.28+). The library must +support the published graph's blocked DequantizeLinear at opset 21. QDQ +fusion and prepacking are disabled, matching the browser and native probe. +A real one-token warmup must pass before HTTP starts. Models are not downloaded. + +```sh +cargo build --release --locked --manifest-path server/Cargo.toml --bin tokenling-server +export TOKENLING_LISTEN=127.0.0.1:$PORT +export TOKENLING_DB=$STATE/pets.sqlite +export TOKENLING_MODEL_DIR=$DATA/release/MODEL_ID +export TOKENLING_MODEL_ID=MODEL_ID +export TOKENLING_ALLOWED_ORIGIN=https://APP_HOST +export ORT_DYLIB_PATH=$ORT_LIBRARY +server/target/release/tokenling-server +``` + +| Variable | Meaning | +|---|---| +| `TOKENLING_LISTEN` | Required socket address, including port. Use a private listener behind HTTPS for other devices. | +| `TOKENLING_DB` | Required SQLite path outside Git; contains private live state. | +| `TOKENLING_MODEL_DIR` | Required export directory with tokenizer/config files and `onnx/model_q4.onnx`. | +| `TOKENLING_MODEL_ID` | Required release ID, matching the browser's selected brain. Transfers never silently upgrade it. | +| `TOKENLING_ALLOWED_ORIGIN` | Required comma-separated browser origins, with scheme/port. Wildcards are rejected. | +| `ORT_DYLIB_PATH` | Required native ONNX Runtime library path. | +| `TOKENLING_THREADS` | Positive CPU intra-op thread count; default 4. Inter-op stays at one. | +| `TOKENLING_GPU_DEVICE` | Optional CUDA device index, requiring a CUDA-enabled ORT library and an admitted GPU allocation. Default CPU only; GPU initialization failure leaves CPU available. | +| `TOKENLING_MAX_PETS` | Registered-pet bound, including parked saves; default 64. This is not measured serving capacity. | +| `TOKENLING_WEATHER_SEED` | Deterministic weather seed; default empty. | +| `TOKENLING_FEATURES` | JSON list of trained release features; default `[]`. | + +Unix database/WAL/SHM permissions are restricted to `0600`. Protect the directory +and backups too. Run one host process per database. TLS belongs at the reverse +proxy; API responses use `Cache-Control: no-store` and the configured CORS +allow-list. Loopback HTTP is supported for previews. Other browser endpoints +must use HTTPS. + +`REMOTE_ENDPOINT` in `site/build.sh` optionally supplies a default host URL. +Without it, owners enter their server address. It does not change the default +local execution mode. + +## API and ownership + +`GET /api/health` advertises protocol 1, release/features and available workers. +Every other route requires `Authorization: Bearer KEY`: + +| Route | Behavior | +|---|---| +| `POST /api/pets` | Prepare an inactive save with a random UUID/key. Retry never overwrites an existing pet. | +| `GET /api/pets/ID` | Private save, epoch/revision, effects, game and thinking status. | +| `POST /api/pets/ID/commands` | Owner command, expected epoch and unique operation ID. | +| `POST /api/pets/ID/transfer` | Activate an uploaded local save remotely, or park the executor and return its latest save. | +| `DELETE /api/pets/ID` | Delete the pet and operation receipts. Late replies cannot recreate it. | + +Save v2 adds `hosting` (`mode`, `brainId`, `timeZone`). Real v1 saves migrate +to local mode without changing pet data. The key remains `tokenling.v1`. +Connection keys, identifiers and transfer intent are separate in +`tokenling.remote.v1`, and exported local recovery saves omit them. +Pending recovery stops live separately in `tokenling.remote-stops.v1` so +switching hosts cannot discard an unreachable old host's key. They retry +every 30 seconds while the owning tab is open and on reload/reconnect. + +Local-to-remote prepares an inactive record, then persists remote intent before +activation. A reload reconciles a lost activation response. Remote-to-local +atomically parks the executor, advances its epoch and returns the latest save. +Parking also fences a prepared record, so an old activation cannot revive it. +The browser resets its body clock before resuming so remote time is not replayed. +A destination-device ID prevents another connected browser from automatically +starting a local copy after that handoff. + +The host owns all state in remote mode. Each pet has one generation at a time. +Committing requires the same epoch and relevant context revision; new owner +interactions cancel stale work. Operation IDs deduplicate retries. Browser +sync ignores older snapshots, and local replies check their execution revision +before applying effects after a mode switch. + +An unreachable host permits explicit recovery from the last browser checkpoint. +Newer server-only progress may be missing. Its copy can keep thinking until the +pending stop reaches it; its eventual snapshot never overwrites newer local +progress. Graceful transfer fences ownership. Offline recovery cannot prove an +unreachable executor has stopped. + +## Simulation and workers + +The host mirrors body rates, growth, care/offer effects, the wake formula, +closed tags, deterministic lookups and trained prompts. The model chooses +pet actions; there are no canned replies or random wandering. The browser +plays visual effects without applying care effects again. Catch starts only +after acceptance; its result commits once with the game ID. An abandoned +game expires without inventing a score. + +CPU is resident. An optional native CUDA session prefers reactive turns, while +CPU prefers background work; a spare worker can handle either. Sessions run +serially per worker. Queue-cost learning, batching and mixed serving capacity +are not established. This CUDA path is distinct from the PyTorch probe and +needs its own performance qualification. + +Foreground sync polls once per second, less often while hidden. The browser +keeps a recovery checkpoint and does not run local simulation in remote mode. +Existing alerts remain browser/device managed; there is no push service or +guaranteed closed-browser notification delivery. + +## Checks and source map + +```sh +cargo fmt --check --manifest-path server/Cargo.toml +cargo test --locked --manifest-path server/Cargo.toml +cargo build --locked --manifest-path server/Cargo.toml --bin tokenling-contract +node server/tests/parity.mjs +``` + +Scripted generators exist only in tests. Tests cover authentication, background +turns without browser polling, both transfers, old-reply fencing, deduplicated +care, prepared-record fencing, memory eviction, GPU failure fallback, restart +persistence, body rules and sleeping offers. Rust/Python parity +covers 300 states and 15 lists, lookups and wake probabilities. Browser checks +are in [testing](../docs/testing.md). + +| Path | Role | +|---|---| +| `src/main.rs` | Configuration, native workers, HTTP/CORS and startup | +| `src/host.rs` | Private API, scheduler, generation/lookup loop and commit fencing | +| `src/store.rs` | SQLite state, key hashes, epochs and idempotent operations | +| `src/types.rs`, `src/simulation.rs` | Saves, body, care, diary state and visual effects | +| `src/world.rs` | Rust mirror of the prompt contract | +| `src/brain.rs` | Tokenizer, context fit, native inference and stop rules | +| `src/bin/contract.rs`, `tests/parity.mjs` | Prompt/list/lookup/wake parity adapter | +| `src/bin/brain-probe.rs` | One Generation from stdin; required `ORT_DYLIB_PATH`, `TOKENLING_MODEL_DIR`, optional `TOKENLING_THREADS=4`. Diagnostic stdout includes the prompt. | +| `tests/host.rs` | API, state-ownership, body and generation regressions | + +The prototype has no ATProto accounts, multiplayer, host-to-host visits, +public-host abuse controls or full app/model export package. Native CPU and +browser checks are recorded when measured. CUDA integration and mixed load +remain unmeasured. The [Axum API](https://docs.rs/axum/latest/axum/) and +[ort sessions](https://docs.rs/ort/2.0.0-rc.13/ort/session/struct.Session.html) +describe dependency interfaces; Cargo.lock pins the build. diff --git a/server/src/bin/brain-probe.rs b/server/src/bin/brain-probe.rs new file mode 100644 index 0000000..7df549e --- /dev/null +++ b/server/src/bin/brain-probe.rs @@ -0,0 +1,20 @@ +//! Run one Generation from stdin. Required: ORT_DYLIB_PATH, TOKENLING_MODEL_DIR. +//! Optional: TOKENLING_THREADS (positive integer, default 4). +use std::io::{self, Read}; +use tokenling_server::brain::{Brain, Generation}; +fn main() -> anyhow::Result<()> { + let library = std::env::var("ORT_DYLIB_PATH") + .map_err(|_| anyhow::anyhow!("set ORT_DYLIB_PATH to the native ONNX Runtime library"))?; + let model = std::env::var("TOKENLING_MODEL_DIR") + .map_err(|_| anyhow::anyhow!("set TOKENLING_MODEL_DIR to an exported brain directory"))?; + ort::init_from(library)?.with_name("tokenling").commit(); + let threads = std::env::var("TOKENLING_THREADS") + .unwrap_or_else(|_| "4".into()) + .parse()?; + let mut brain = Brain::load(std::path::Path::new(&model), threads, None)?; + let mut raw = String::new(); + io::stdin().read_to_string(&mut raw)?; + let generation: Generation = serde_json::from_str(&raw)?; + println!("{}", serde_json::to_string(&brain.generate(&generation)?)?); + Ok(()) +} diff --git a/server/src/bin/contract.rs b/server/src/bin/contract.rs new file mode 100644 index 0000000..7eeac7d --- /dev/null +++ b/server/src/bin/contract.rs @@ -0,0 +1,34 @@ +//! JSON stdin/stdout adapter for Python/TypeScript/Rust prompt parity checks. +use serde::Deserialize; +use serde_json::json; +use std::io::{self, Read}; +use tokenling_server::{types::WorldState, world}; +#[derive(Deserialize)] +struct Case { + st: WorldState, + q: String, +} +#[derive(Deserialize)] +struct Input { + cases: Vec, +} +fn sorted<'a>(values: &[&'a str]) -> Vec<&'a str> { + let mut values = values.to_vec(); + values.sort(); + values +} +fn main() -> anyhow::Result<()> { + let mut raw = String::new(); + io::stdin().read_to_string(&mut raw)?; + let input: Input = serde_json::from_str(&raw)?; + let rows:Vec<_>=input.cases.iter().map(|c|{ + let s=&c.st; + let wake:Vec<_>=["talk","touch","light"].iter().flat_map(|kind|(1..=6).map(move|n|world::wake_chance(s.stats.energy,kind,n))).collect(); + json!({"chat":world::header("chat",s),"diary":world::header("diary",s),"clock":world::tool_result(s,"clock",""),"look":world::tool_result(s,"look",""),"recall":world::tool_result(s,"recall",&c.q),"wake":wake}) + }).collect(); + println!( + "{}", + json!({"cases":rows,"lists":{"STAGES":world::STAGES,"PERSONALITIES":world::PERSONALITIES,"FOODS":world::FOODS,"GIFTS":world::GIFTS,"DREAMS":world::DREAMS,"EMOTES":world::EMOTES,"WANTS":world::WANTS,"WALKS":world::WALKS,"DOODLES":world::DOODLES,"WEATHER_DAY":world::WEATHER_DAY,"WEATHER_NIGHT":world::WEATHER_NIGHT,"TOYS":world::TOYS,"EFFECT_TOOLS":sorted(world::EFFECT_TOOLS),"LOOKUP_TOOLS":sorted(world::LOOKUP_TOOLS),"ANSWER_TOOLS":sorted(world::ANSWER_TOOLS)}}) + ); + Ok(()) +} diff --git a/server/src/brain.rs b/server/src/brain.rs new file mode 100644 index 0000000..f4f5852 --- /dev/null +++ b/server/src/brain.rs @@ -0,0 +1,310 @@ +//! One mutable ONNX session per worker. No concurrent calls into a session. +use anyhow::{Context, Result, ensure}; +use ort::{ + session::Session, + value::{DynValue, Tensor}, +}; +use serde::{Deserialize, Serialize}; +use std::{path::Path, time::Instant}; +use tokenizers::Tokenizer; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Generation { + pub header: String, + pub lines: Vec, + pub prefix: String, + pub mode: String, + pub max_new: usize, + pub temperature: f32, + pub ctx: usize, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Generated { + pub text: String, + pub prompt: String, + pub reason: String, + pub prompt_tokens: usize, + pub prefill_ms: f64, + pub tps: f64, +} +pub struct Brain { + session: Session, + tokenizer: Tokenizer, + past_names: Vec, + inputs: Vec, + heads: usize, + width: usize, +} +impl Brain { + pub fn load(model_dir: &Path, threads: usize, gpu: Option) -> Result { + let tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json")) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(model_dir.join("config.json"))?)?; + let heads = cfg["n_head"].as_u64().context("config requires n_head")? as usize; + let width = cfg["n_embd"].as_u64().context("config requires n_embd")? as usize / heads; + let mut builder = Session::builder()?; + builder = builder + .with_intra_threads(threads) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + builder = builder + .with_inter_threads(1) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + for (key, value) in [ + ("session.disable_quant_qdq", "1"), + ("session.disable_prepacking", "1"), + ("session.intra_op.allow_spinning", "0"), + ] { + builder = builder + .with_config_entry(key, value) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + } + if let Some(device) = gpu { + builder = builder + .with_execution_providers([ort::ep::CUDA::default() + .with_device_id(device) + .build() + .error_on_failure()]) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + } + let session = builder + .commit_from_file(model_dir.join("onnx/model_q4.onnx")) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let inputs: Vec<_> = session + .inputs() + .iter() + .map(|i| i.name().to_owned()) + .collect(); + let past_names = inputs + .iter() + .filter(|n| n.starts_with("past_key_values.")) + .cloned() + .collect(); + let mut brain = Self { + session, + tokenizer, + past_names, + inputs, + heads, + width, + }; + brain.generate(&Generation { + header: String::new(), + lines: vec![], + prefix: "hi".into(), + mode: "line".into(), + max_new: 1, + temperature: 0.0, + ctx: 1024, + })?; + Ok(brain) + } + pub fn count(&self, text: &str) -> Result { + Ok(self + .tokenizer + .encode(text, false) + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .len()) + } + pub fn fit(&self, g: &Generation) -> Result { + let budget = g + .ctx + .min(1024) + .checked_sub(g.max_new + 4) + .context("generation budget exceeds context")?; + let mut used = self.count(&g.header)? + self.count(&g.prefix)?; + ensure!( + used <= budget, + "prompt header does not fit the selected brain context" + ); + let mut kept = Vec::new(); + for line in g.lines.iter().rev() { + let size = self.count(&(line.clone() + "\n"))?; + if used + size > budget { + break; + } + kept.push(line.clone()); + used += size; + } + kept.reverse(); + Ok(g.header.clone() + + &kept.iter().map(|l| l.clone() + "\n").collect::() + + &g.prefix) + } + pub fn generate(&mut self, g: &Generation) -> Result { + self.generate_cancellable(g, || false) + } + pub fn generate_cancellable( + &mut self, + g: &Generation, + cancelled: impl Fn() -> bool, + ) -> Result { + ensure!((1..=220).contains(&g.max_new), "invalid output budget"); + ensure!( + matches!(g.mode.as_str(), "line" | "diary"), + "invalid generation mode" + ); + ensure!( + g.temperature.is_finite() && (0.0..=2.0).contains(&g.temperature), + "invalid temperature" + ); + let prompt = self.fit(g)?; + let encoded = self + .tokenizer + .encode(prompt.clone(), false) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let ids: Vec = encoded.get_ids().iter().map(|i| *i as i64).collect(); + let prompt_tokens = ids.len(); + let mut current = ids; + let mut generated = Vec::new(); + let mut past: Vec<(String, DynValue)> = self + .past_names + .iter() + .map(|name| { + Ok(( + name.clone(), + Tensor::::from_array(([1, self.heads, 0, self.width], Vec::::new()))? + .into_dyn(), + )) + }) + .collect::>()?; + let started = Instant::now(); + let mut prefill_ms = 0.0; + let mut reason = "length"; + let mut text = String::new(); + for step in 0..g.max_new { + ensure!(!cancelled(), "generation cancelled"); + let total = prompt_tokens + step; + let positions: Vec = if step == 0 { + (0..prompt_tokens as i64).collect() + } else { + vec![(total - 1) as i64] + }; + let mut input = vec![ + ( + "input_ids".into(), + Tensor::::from_array(([1, current.len()], current))?.into_dyn(), + ), + ( + "attention_mask".into(), + Tensor::::from_array(([1, total], vec![1; total]))?.into_dyn(), + ), + ( + "position_ids".into(), + Tensor::::from_array(([1, positions.len()], positions))?.into_dyn(), + ), + ]; + input.retain(|(name, _)| self.inputs.contains(name)); + input.append(&mut past); + let mut output = self.session.run(input)?; + let (_, logits) = output["logits"].try_extract_tensor::()?; + let token = sample(&logits[logits.len() - 50257..], g.temperature)?; + if step == 0 { + prefill_ms = started.elapsed().as_secs_f64() * 1000.0; + } + if token == 50256 { + reason = "eos"; + break; + } + generated.push(token); + text = self + .tokenizer + .decode(&generated, true) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + if g.mode == "line" { + if let Some(at) = text.find('\n') { + text.truncate(at); + reason = "line"; + break; + } + if lookup_tail(&text).is_some() { + text = text + .strip_suffix("][") + .map(|s| s.to_owned() + "]") + .unwrap_or(text); + reason = "lookup"; + break; + } + } else if let Some(at) = text.find("\n##").or_else(|| text.find("\n\n\n")) { + text.truncate(at); + reason = "end"; + break; + } + for name in &self.past_names { + let value = output + .remove(name.replace("past_key_values.", "present.")) + .context("missing KV output")?; + past.push((name.clone(), value)); + } + current = vec![token as i64]; + } + let decode_s = (started.elapsed().as_secs_f64() - prefill_ms / 1000.0).max(1e-6); + Ok(Generated { + text, + prompt, + reason: reason.into(), + prompt_tokens, + prefill_ms, + tps: generated.len().saturating_sub(1) as f64 / decode_s, + }) + } +} + +pub fn lookup_tail(text: &str) -> Option<(String, String)> { + static LOOKUP: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"\[(clock|look|yesterday|recall)(?::([^\]\n]{1,80}))?\]\[?$").unwrap() + }); + let c = LOOKUP.captures(text)?; + let name = c[1].to_owned(); + let arg = c.get(2).map_or("", |m| m.as_str()).to_owned(); + if name == "recall" && arg.is_empty() { + return None; + } + if name != "recall" && !arg.is_empty() { + return None; + } + Some((name, arg)) +} +fn sample(logits: &[f32], temperature: f32) -> Result { + ensure!( + logits.iter().all(|v| v.is_finite()), + "non-finite model logits" + ); + if temperature == 0.0 { + return Ok(logits + .iter() + .enumerate() + .max_by(|a, b| a.1.total_cmp(b.1)) + .unwrap() + .0 as u32); + } + let mut ranked: Vec<_> = logits.iter().copied().enumerate().collect(); + ranked.select_nth_unstable_by(39, |a, b| b.1.total_cmp(&a.1)); + ranked.truncate(40); + ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1)); + let high = ranked[0].1; + let probabilities: Vec<_> = ranked + .iter() + .map(|(_, v)| ((*v - high) / temperature).exp()) + .collect(); + let total: f32 = probabilities.iter().sum(); + let mut kept = 0.0; + let mut n = 0; + for p in &probabilities { + kept += p; + n += 1; + if kept / total >= 0.92 { + break; + } + } + let mut draw = rand::random::() * kept; + for (i, p) in probabilities.iter().take(n).enumerate() { + draw -= p; + if draw <= 0.0 { + return Ok(ranked[i].0 as u32); + } + } + Ok(ranked[n - 1].0 as u32) +} diff --git a/server/src/host.rs b/server/src/host.rs new file mode 100644 index 0000000..604fda7 --- /dev/null +++ b/server/src/host.rs @@ -0,0 +1,579 @@ +//! Private capability API and background scheduler. The host owns all state. +use crate::{ + brain::{Brain, Generated, Generation, lookup_tail}, + simulation::{self, Command, HostedPet}, + store::{AccessDenied, Conflict, Store}, + types::{DiaryPage, PetState}, + world, +}; +use anyhow::{Result, ensure}; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::sync::{Notify, Semaphore}; + +pub trait Generator: Send { + fn generate(&mut self, g: &Generation, cancel: &AtomicBool) -> Result; +} +impl Generator for Brain { + fn generate(&mut self, g: &Generation, cancel: &AtomicBool) -> Result { + self.generate_cancellable(g, || cancel.load(Ordering::Relaxed)) + } +} +pub struct Worker { + pub name: &'static str, + pub engine: Arc>>, + pub slot: Arc, + pub available: Arc, +} +impl Worker { + pub fn new(name: &'static str, engine: impl Generator + 'static) -> Self { + Self { + name, + engine: Arc::new(Mutex::new(Box::new(engine))), + slot: Arc::new(Semaphore::new(1)), + available: Arc::new(AtomicBool::new(true)), + } + } +} +pub struct Lease { + pub epoch: u64, + pub context: u64, + pub cancel: Arc, +} +pub struct Host { + pub db: Mutex, + pub model_id: String, + pub features: Vec, + pub max_pets: usize, + pub weather_seed: String, + pub cpu: Worker, + pub gpu: Option, + pub jobs: Mutex>, + pub notify: Notify, +} +impl Host { + pub fn snapshot(&self, id: &str, row: HostedPet) -> Value { + let thinking = self.jobs.lock().unwrap().contains_key(id) || row.pending.is_some(); + let t = simulation::now(); + let weather = simulation::weather_at(&row.state, t, &self.weather_seed); + json!({"state":row.state,"epoch":row.epoch,"revision":row.revision,"authority":row.authority,"localDevice":row.local_device,"effects":row.effects,"game":row.game,"thinking":thinking,"lastError":row.last_error,"modelId":self.model_id,"features":self.features,"serverNow":t,"weather":weather}) + } + fn cancel(&self, id: &str) { + if let Some(job) = self.jobs.lock().unwrap().get(id) { + job.cancel.store(true, Ordering::Relaxed); + } + } + fn cancel_stale(&self, id: &str, row: &HostedPet) { + if let Some(job) = self.jobs.lock().unwrap().get(id) { + if row.authority != "remote" + || job.epoch != row.epoch + || job.context != row.context_revision + { + job.cancel.store(true, Ordering::Relaxed); + } + } + } + pub fn router(self: Arc) -> Router { + Router::new() + .route("/api/health", get(health)) + .route("/api/pets", post(create)) + .route("/api/pets/{id}", get(snapshot).delete(delete)) + .route("/api/pets/{id}/commands", post(command)) + .route("/api/pets/{id}/transfer", post(transfer)) + .layer(DefaultBodyLimit::max(8 * 1024 * 1024)) + .with_state(self) + } + pub async fn run(self: Arc) { + loop { + if let Err(error) = self.dispatch() { + eprintln!("pet scheduler failed: {error}"); + } + tokio::select! {_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => {}, _ = self.notify.notified() => {}} + } + } + fn dispatch(self: &Arc) -> Result<()> { + let t = simulation::now(); + let mut rows = self.db.lock().unwrap().advance(t, &self.weather_seed)?; + rows.sort_by(|a, b| { + b.1.pending + .is_some() + .cmp(&a.1.pending.is_some()) + .then_with(|| a.1.last_turn.total_cmp(&b.1.last_turn)) + }); + for (id, row) in rows { + if row.authority != "remote" || row.state.pet.is_none() || row.game.is_some() { + continue; + } + { + let jobs = self.jobs.lock().unwrap(); + if let Some(job) = jobs.get(&id) { + if row.epoch != job.epoch || row.context_revision != job.context { + job.cancel.store(true, Ordering::Relaxed); + } + continue; + } + } + let Some(task) = choose_task(&row, t) else { + continue; + }; + let preferred: Vec<&Worker> = if matches!(task,Task::Chat(ref k) if k=="react") { + self.gpu.iter().chain(std::iter::once(&self.cpu)).collect() + } else { + std::iter::once(&self.cpu).chain(self.gpu.iter()).collect() + }; + let Some((worker, permit)) = preferred + .into_iter() + .filter(|w| w.available.load(Ordering::Relaxed)) + .find_map(|w| w.slot.clone().try_acquire_owned().ok().map(|p| (w, p))) + else { + break; + }; + let engine = worker.engine.clone(); + let availability = worker.available.clone(); + let backend = worker.name; + let retry_task = task.clone(); + let cancel = Arc::new(AtomicBool::new(false)); + let captured = + self.db + .lock() + .unwrap() + .commit(&id, row.epoch, row.context_revision, |r| { + r.pending = None; + r.last_turn = t; + r.schedule_idle(t); + r.revision += 1; + }); + if !captured? { + continue; + } + self.jobs.lock().unwrap().insert( + id.clone(), + Lease { + epoch: row.epoch, + context: row.context_revision, + cancel: cancel.clone(), + }, + ); + let host = self.clone(); + let seed = self.weather_seed.clone(); + tokio::spawn(async move { + let captured = row.clone(); + let cancelled = cancel.clone(); + let result = tokio::task::spawn_blocking(move || { + let mut engine = engine + .lock() + .map_err(|_| anyhow::anyhow!("brain worker is unavailable"))?; + execute(&captured, task, engine.as_mut(), &cancelled, t, &seed) + }) + .await; + let outcome = + result.unwrap_or_else(|_| Err(anyhow::anyhow!("brain worker stopped"))); + let gpu_failed = + backend == "gpu" && outcome.is_err() && !cancel.load(Ordering::Relaxed); + if gpu_failed { + availability.store(false, Ordering::Relaxed); + } + let at = simulation::now(); + let committed = host.db.lock().unwrap().commit( + &id, + row.epoch, + row.context_revision, + |current| { + if cancel.load(Ordering::Relaxed) { + return; + } + match outcome { + Ok(Output::Chat(body)) => { + if !body.is_empty() + && body.bytes().any(|b| b.is_ascii_alphanumeric() || b == b'_') + { + current.push_line(format!( + "{}: {body}", + current.state.pet.as_ref().unwrap().name + )); + current.apply_line(&body, at, &host.weather_seed); + } else if current.state.offer.is_some() { + current.answer_offer(false, at, &host.weather_seed); + } + current.last_error = None; + current.revision += 1; + } + Ok(Output::Diary(page)) => { + current.state.history.remove(&page.date); + if !current.state.diary.iter().any(|d| d.date == page.date) { + current.state.diary.push(page); + } + current.last_error = None; + current.revision += 1; + } + Err(_) => { + current.last_error = Some( + "The brain could not finish this turn. It will retry.".into(), + ); + current.next_idle = at + if gpu_failed { 100.0 } else { 5000.0 }; + if gpu_failed { + if let Task::Chat(kind) = &retry_task { + current.pending = Some(kind.clone()); + } + } + current.revision += 1; + } + } + }, + ); + if let Err(error) = committed { + eprintln!("pet turn commit failed: {error}"); + } + host.jobs.lock().unwrap().remove(&id); + drop(permit); + host.notify.notify_one(); + }); + } + Ok(()) + } +} + +#[derive(Clone)] +enum Task { + Chat(String), + Diary(String), +} +enum Output { + Chat(String), + Diary(DiaryPage), +} +fn choose_task(row: &HostedPet, t: f64) -> Option { + if let Some(kind) = &row.pending { + return Some(Task::Chat(kind.clone())); + } + if row.last_error.is_some() && t < row.next_idle { + return None; + } + if row.state.asleep + && world::part_of_day(row.world(t, "").hh) == "night" + && row.state.today.log.log.len() >= 2 + && t - row.last_turn >= 1500.0 + && !row + .state + .diary + .iter() + .any(|d| d.date == row.state.today.date) + { + return Some(Task::Diary(row.state.today.date.clone())); + } + if t >= row.next_idle { + return Some(Task::Chat("idle".into())); + } + if t - row.state.last_active >= 30000.0 { + if let Some((date, _)) = row.state.history.first_key_value() { + return Some(Task::Diary(date.clone())); + } + } + None +} +fn execute( + row: &HostedPet, + task: Task, + engine: &mut dyn Generator, + cancel: &AtomicBool, + t: f64, + seed: &str, +) -> Result { + let mut st = row.world(t, seed); + match task { + Task::Chat(kind) => { + let mut body = String::new(); + for _ in 0..3 { + body.clear(); + for hop in 0..4 { + let g = Generation { + header: world::header("chat", &st), + lines: row.state.chat.clone(), + prefix: format!("{}:{body}", st.pet), + mode: "line".into(), + max_new: if kind == "idle" { 40 } else { 64 }, + temperature: 0.85, + ctx: if kind == "idle" { 512 } else { 1024 }, + }; + let reply = engine.generate(&g, cancel)?; + body += &reply.text; + if reply.reason != "lookup" || hop == 3 { + break; + } + let Some((name, arg)) = lookup_tail(&body) else { + break; + }; + if let Some(result) = world::tool_result(&st, &name, &arg) { + body += &format!("{{{result}}}"); + } + } + if !world::NOT_CUTE.is_match(&body) { + return Ok(Output::Chat(world::compact(&body))); + } + } + Ok(Output::Chat(String::new())) + } + Task::Diary(date) => { + let src = if date == row.state.today.date { + row.state.today.log.clone() + } else { + row.state + .history + .get(&date) + .cloned() + .context("diary day disappeared")? + }; + st.today = src.log; + st.weather = src.weather.unwrap_or(st.weather); + st.day = src.day.unwrap_or(st.day); + let reply = engine.generate( + &Generation { + header: world::header("diary", &st), + lines: vec![], + prefix: "dear diary,".into(), + mode: "diary".into(), + max_new: 220, + temperature: 0.85, + ctx: 1024, + }, + cancel, + )?; + let text = format!("dear diary,{}", reply.text); + let doodles: Vec<_> = world::TAG + .captures_iter(&text) + .filter(|c| &c[1] == "doodle") + .filter_map(|c| c.get(2)) + .map(|m| m.as_str().to_owned()) + .filter(|s| world::DOODLES.contains(&s.as_str())) + .take(3) + .collect(); + // Filter the entire unsafe entry rather than publishing its fragments. + let text = if world::NOT_CUTE.is_match(&text) { + String::new() + } else { + world::clean_text(&text) + }; + ensure!(!text.is_empty(), "diary text was filtered"); + Ok(Output::Diary(DiaryPage { + date, + day: st.day, + weather: st.weather, + stage: st.stage, + text, + doodles, + })) + } + } +} + +use anyhow::Context; +struct ApiError(anyhow::Error); +impl From for ApiError { + fn from(e: anyhow::Error) -> Self { + Self(e) + } +} +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = if self.0.is::() { + StatusCode::UNAUTHORIZED + } else if self.0.is::() { + StatusCode::CONFLICT + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(json!({"error":self.0.to_string()}))).into_response() + } +} +type ApiResult = std::result::Result, ApiError>; +fn key(headers: &HeaderMap) -> Result { + let key = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(AccessDenied.into()); + } + Ok(key.into()) +} +async fn health(State(host): State>) -> Json { + Json( + json!({"protocol":1,"modelId":host.model_id,"features":host.features,"cpu":!host.cpu.engine.is_poisoned(),"gpu":host.gpu.as_ref().is_some_and(|w|w.available.load(Ordering::Relaxed))}), + ) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Create { + id: String, + state: PetState, + model_id: String, +} +async fn create( + State(host): State>, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult { + ensure_model(&host, &input.model_id)?; + let key = key(&headers)?; + let row = host.db.lock().unwrap().create( + &input.id, + &key, + HostedPet::new(input.state, simulation::now()), + host.max_pets, + )?; + Ok(Json(host.snapshot(&input.id, row))) +} +fn ensure_model(host: &Host, id: &str) -> Result<()> { + ensure!( + host.model_id == id, + "this host does not serve the pet's selected brain" + ); + Ok(()) +} +async fn snapshot( + State(host): State>, + Path(id): Path, + headers: HeaderMap, +) -> ApiResult { + let row = host.db.lock().unwrap().get(&id, &key(&headers)?)?; + Ok(Json(host.snapshot(&id, row))) +} +async fn delete( + State(host): State>, + Path(id): Path, + headers: HeaderMap, +) -> ApiResult { + host.db.lock().unwrap().delete(&id, &key(&headers)?)?; + host.cancel(&id); + Ok(Json(json!({"deleted":true}))) +} +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CommandRequest { + epoch: u64, + operation_id: String, + command: Command, +} +async fn command( + State(host): State>, + Path(id): Path, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult { + let body = serde_json::to_string(&input).unwrap(); + let command = input.command; + let row = host.db.lock().unwrap().operation( + &id, + &key(&headers)?, + input.epoch, + &input.operation_id, + &body, + |r| r.command(command, simulation::now(), &host.weather_seed), + )?; + host.cancel_stale(&id, &row); + host.notify.notify_one(); + Ok(Json(host.snapshot(&id, row))) +} +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Transfer { + epoch: u64, + operation_id: String, + mode: String, + device_id: String, + state: Option, +} +async fn transfer( + State(host): State>, + Path(id): Path, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult { + let body = serde_json::to_string(&input).unwrap(); + if uuid::Uuid::parse_str(&input.device_id).is_err() { + return Err(anyhow::anyhow!("invalid device identifier").into()); + } + let row = host.db.lock().unwrap().operation( + &id, + &key(&headers)?, + input.epoch, + &input.operation_id, + &body, + |r| { + let t = simulation::now(); + match input.mode.as_str() { + "remote" => { + if r.authority != "local" { + return Err(Conflict("this pet already has a remote executor").into()); + } + if r.local_device + .as_ref() + .is_some_and(|d| d != &input.device_id) + { + return Err(Conflict("this pet has moved to another device").into()); + } + let state = input + .state + .context("local save is required for remote activation")?; + state.validate()?; + ensure_model( + &host, + state.hosting.brain_id.as_deref().unwrap_or(&host.model_id), + )?; + r.state = state; + r.state.hosting.mode = "remote".into(); + r.state.hosting.brain_id = Some(host.model_id.clone()); + r.authority = "remote".into(); + r.local_device = None; + r.pending = r.state.pet.as_ref().map(|_| "react".into()); + r.next_idle = t + 4000.0; + r.effects.clear(); + r.last_error = None; + } + "local" => { + if r.authority == "local" + && r.local_device + .as_ref() + .is_some_and(|d| d != &input.device_id) + { + return Err(Conflict("this pet has moved to another device").into()); + } + ensure!( + r.game.is_none(), + "finish the current game before moving the pet" + ); + if r.authority == "remote" { + r.advance(t, &host.weather_seed); + } + r.authority = "local".into(); + r.local_device = Some(input.device_id); + r.state.hosting.mode = "local".into(); + r.pending = None; + } + _ => anyhow::bail!("unknown execution mode"), + } + r.epoch += 1; + r.context_revision += 1; + r.revision += 1; + Ok(()) + }, + )?; + host.cancel_stale(&id, &row); + host.notify.notify_one(); + Ok(Json(host.snapshot(&id, row))) +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..52d1e06 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,6 @@ +pub mod brain; +pub mod host; +pub mod simulation; +pub mod store; +pub mod types; +pub mod world; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..f48ad81 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,100 @@ +//! Required env: TOKENLING_LISTEN, TOKENLING_DB, TOKENLING_MODEL_DIR, +//! TOKENLING_MODEL_ID, TOKENLING_ALLOWED_ORIGIN, ORT_DYLIB_PATH. +//! Optional: TOKENLING_THREADS=4; TOKENLING_GPU_DEVICE (physical CUDA index, +//! requires a CUDA-enabled ORT library); TOKENLING_MAX_PETS=64; +//! TOKENLING_WEATHER_SEED=""; TOKENLING_FEATURES="[]" (JSON feature names). +//! No state, access keys, prompts or generated text are logged. +use anyhow::{Context, Result, ensure}; +use axum::http::{HeaderValue, Method, header}; +use std::{ + env, + path::Path, + sync::{Arc, Mutex}, +}; +use tokenling_server::{ + brain::Brain, + host::{Host, Worker}, + store::Store, +}; +use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer}; +fn required(name: &str) -> Result { + env::var(name).with_context(|| format!("set {name}")) +} +#[tokio::main] +async fn main() -> Result<()> { + let listen: std::net::SocketAddr = required("TOKENLING_LISTEN")?.parse()?; + let db = required("TOKENLING_DB")?; + let model_dir = required("TOKENLING_MODEL_DIR")?; + let model_id = required("TOKENLING_MODEL_ID")?; + let origins: Vec = required("TOKENLING_ALLOWED_ORIGIN")? + .split(',') + .map(|s| s.trim().parse()) + .collect::>()?; + ensure!( + !origins.is_empty() && origins.iter().all(|s| s.as_bytes() != b"*"), + "configure explicit allowed browser origins" + ); + let threads: usize = env::var("TOKENLING_THREADS") + .unwrap_or_else(|_| "4".into()) + .parse()?; + ensure!(threads > 0, "TOKENLING_THREADS must be positive"); + let max_pets: usize = env::var("TOKENLING_MAX_PETS") + .unwrap_or_else(|_| "64".into()) + .parse()?; + ensure!(max_pets > 0, "TOKENLING_MAX_PETS must be positive"); + ort::init_from(required("ORT_DYLIB_PATH")?)? + .with_name("tokenling") + .commit(); + let cpu = Worker::new("cpu", Brain::load(Path::new(&model_dir), threads, None)?); + let gpu = if let Ok(device) = env::var("TOKENLING_GPU_DEVICE") { + match Brain::load(Path::new(&model_dir), 1, Some(device.parse()?)) { + Ok(brain) => Some(Worker::new("gpu", brain)), + Err(error) => { + eprintln!("optional CUDA worker unavailable: {error}"); + None + } + } + } else { + None + }; + let host = Arc::new(Host { + db: Mutex::new(Store::open(Path::new(&db))?), + model_id, + features: serde_json::from_str( + &env::var("TOKENLING_FEATURES").unwrap_or_else(|_| "[]".into()), + )?, + max_pets, + weather_seed: env::var("TOKENLING_WEATHER_SEED").unwrap_or_default(), + cpu, + gpu, + jobs: Mutex::new(std::collections::HashMap::new()), + notify: tokio::sync::Notify::new(), + }); + let router = host + .clone() + .router() + .layer( + CorsLayer::new() + .allow_origin(origins) + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]), + ) + .layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("cross-origin"), + )); + let listener = tokio::net::TcpListener::bind(listen).await?; + let scheduler = tokio::spawn(host.run()); + eprintln!("tokenling host listening on {listen}"); + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + scheduler.abort(); + Ok(()) +} diff --git a/server/src/simulation.rs b/server/src/simulation.rs new file mode 100644 index 0000000..306e603 --- /dev/null +++ b/server/src/simulation.rs @@ -0,0 +1,830 @@ +//! The pet's body and consequences of model tags; choices remain in the brain. +use crate::{types::*, world}; +use anyhow::{Result, ensure}; +use chrono::{Datelike, TimeZone, Timelike}; +use serde::{Deserialize, Serialize}; + +pub const MIN: f64 = 60_000.0; +pub const HOUR: f64 = 60.0 * MIN; +pub const DAY: f64 = 24.0 * HOUR; +pub fn now() -> f64 { + chrono::Utc::now().timestamp_millis() as f64 +} +pub fn clamp(n: f64) -> f64 { + n.clamp(0.0, 100.0) +} +fn date(state: &PetState, t: f64) -> chrono::DateTime { + let tz = state + .hosting + .time_zone + .parse::() + .unwrap_or(chrono_tz::UTC); + tz.timestamp_millis_opt(t as i64) + .single() + .unwrap_or_else(|| tz.timestamp_millis_opt(0).unwrap()) +} +pub fn date_key(state: &PetState, t: f64) -> String { + date(state, t).format("%Y-%m-%d").to_string() +} +pub fn day_num(state: &PetState, t: f64) -> i64 { + state + .pet + .as_ref() + .map_or(1, |p| ((t - p.hatched) / DAY).floor() as i64 + 1) +} +pub fn stage_for(age: f64) -> &'static str { + if age >= 5.0 * DAY { + "adult" + } else if age >= 2.0 * DAY { + "teen" + } else if age >= 12.0 * HOUR { + "kid" + } else { + "baby" + } +} +pub fn weather_at(state: &PetState, t: f64, seed: &str) -> String { + let d = date(state, t); + let night = world::part_of_day(d.hour()) == "night"; + let text = date_key(state, t) + if night { "n" } else { "d" } + seed; + let mut hash = 2166136261u32; + // JS iterates code points, then charCodeAt(0) takes the first UTF-16 unit. + for c in text.chars() { + let mut units = [0u16; 2]; + hash = (hash ^ c.encode_utf16(&mut units)[0] as u32).wrapping_mul(16777619); + } + let snow = d.month0() < 2 || d.month0() == 11; + let day = [ + "sunny", + "sunny", + "cloudy", + "cloudy", + "rainy", + "windy", + "foggy", + if snow { "snowy" } else { "sunny" }, + ]; + let nite = [ + "clear", + "starry", + "starry", + "cloudy", + "rainy", + "windy", + "clear", + if snow { "snowy" } else { "clear" }, + ]; + if night { + nite[hash as usize % 8] + } else { + day[hash as usize % 8] + } + .into() +} + +pub fn simulate_body( + state: &mut PetState, + from: f64, + to: f64, + quiet: bool, + random: &mut impl FnMut() -> f64, +) -> Vec { + let mut t = from; + let mut events = Vec::new(); + while t < to { + let dt = (to - t).min(if quiet { 10.0 * MIN } else { to - t }); + let rate = if state.asleep { + [-2.0, 0.0, 22.0, -0.5] + } else { + [-8.0, -6.0, -5.0, -3.0] + }; + let h = dt / HOUR; + state.stats.tummy = clamp(state.stats.tummy + rate[0] * h); + state.stats.fun = clamp(state.stats.fun + rate[1] * h * if state.sick { 2.0 } else { 1.0 }); + state.stats.energy = clamp(state.stats.energy + rate[2] * h); + state.stats.clean = clamp(state.stats.clean + rate[3] * h); + t += dt; + if state.pet.is_none() { + continue; + } + if !state.asleep && t >= state.next_poop { + if state.poop < 4 { + state.poop += 1; + state.stats.clean = clamp(state.stats.clean - 15.0); + events.push("pooped".into()); + } + state.next_poop = t + (2.5 + random() * 2.5) * HOUR; + } + let grimy = state.poop >= 3 || state.stats.tummy < 5.0 || state.stats.clean < 5.0; + if !grimy { + state.grime_since = 0.0; + } else if state.asleep { + if state.grime_since != 0.0 { + state.grime_since += dt; + } + } else if state.grime_since == 0.0 { + state.grime_since = t; + } + if !state.asleep && !state.sick && state.grime_since != 0.0 && t - state.grime_since > HOUR + { + state.sick = true; + events.push("sick".into()); + } + if !state.asleep && state.stats.energy < 6.0 && t - state.woke_at > 5.0 * MIN { + state.asleep = true; + events.push("fell asleep".into()); + } + let hr = date(state, t).hour(); + if state.asleep + && (state.stats.energy >= 99.0 + || (state.stats.energy > 70.0 && (7..11).contains(&hr) && !state.lights_off)) + { + state.asleep = false; + state.lights_off = false; + events.push("woke".into()); + } + if state.stats.tummy < 15.0 { + events.push("hungry".into()); + } + if state.stats.clean < 20.0 { + events.push("stinky".into()); + } + } + events +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Effect { + pub seq: u64, + pub kind: String, + pub arg: String, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Game { + pub id: String, + pub started: f64, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostedPet { + pub state: PetState, + pub epoch: u64, + pub revision: u64, + pub context_revision: u64, + pub authority: String, + pub local_device: Option, + pub pending: Option, + pub next_idle: f64, + pub last_turn: f64, + pub game: Option, + pub effects: Vec, + pub effect_seq: u64, + pub disturbance_count: u32, + pub disturbance_last: f64, + pub last_seen: f64, + pub last_error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Command { + Hatch { + name: String, + }, + Message { + text: String, + }, + Care { + kind: String, + item: Option, + }, + Rename { + name: String, + }, + Chatter { + value: bool, + }, + ForgetMemory { + index: usize, + fact: String, + }, + GameComplete { + #[serde(rename = "gameId")] + game_id: String, + score: u32, + }, +} +fn clean_name(name: &str, max: usize) -> String { + name.trim() + .chars() + .filter(|c| ![':', '*', '[', ']', '{', '}', '\n'].contains(c)) + .take(max) + .collect() +} +fn pick(items: &[&str]) -> String { + items[(rand::random::() * items.len() as f64) as usize].into() +} + +impl HostedPet { + pub fn new(state: PetState, t: f64) -> Self { + Self { + state, + epoch: 0, + revision: 0, + context_revision: 0, + authority: "local".into(), + local_device: None, + pending: None, + next_idle: t + 4000.0, + last_turn: t, + game: None, + effects: vec![], + effect_seq: 0, + disturbance_count: 0, + disturbance_last: 0.0, + last_seen: t, + last_error: None, + } + } + pub fn effect(&mut self, kind: &str, arg: &str) { + self.effect_seq += 1; + self.effects.push(Effect { + seq: self.effect_seq, + kind: kind.into(), + arg: arg.into(), + }); + if self.effects.len() > 32 { + self.effects.remove(0); + } + } + pub fn push_line(&mut self, line: String) { + if let Some(p) = &self.state.pet { + let prefix = p.name.clone() + ": "; + if line.strip_prefix(&prefix).is_some_and(world::silent) + && self + .state + .chat + .iter() + .rev() + .take(2) + .filter(|l| l.strip_prefix(&prefix).is_some_and(world::silent)) + .count() + == 2 + { + self.state.chat.remove(self.state.chat.len() - 2); + } + } + self.state.chat.push(line); + if self.state.chat.len() > 240 { + self.state.chat.remove(0); + } + self.context_revision += 1; + } + pub fn roll_day(&mut self, t: f64, seed: &str) { + let k = date_key(&self.state, t); + if self.state.today.date == k { + return; + } + if self.state.today.log.log.len() >= 2 + && !self + .state + .diary + .iter() + .any(|d| d.date == self.state.today.date) + { + self.state.history.insert( + self.state.today.date.clone(), + DayLog { + log: self.state.today.log.log.clone(), + weather: Some( + self.state + .today + .log + .weather + .clone() + .unwrap_or_else(|| weather_at(&self.state, t - DAY, seed)), + ), + day: Some( + self.state + .today + .log + .day + .unwrap_or_else(|| day_num(&self.state, t) - 1), + ), + }, + ); + } + while self.state.history.len() > 7 { + self.state.history.pop_first(); + } + self.state.today = Today { + date: k, + log: DayLog { + log: vec![], + weather: Some(weather_at(&self.state, t, seed)), + day: Some(day_num(&self.state, t)), + }, + }; + } + pub fn log(&mut self, text: String, t: f64, seed: &str) { + self.roll_day(t, seed); + self.state.today.log.log.retain(|s| *s != text); + self.state.today.log.log.push(text); + if self.state.today.log.log.len() > 14 { + self.state.today.log.log.remove(0); + } + } + pub fn wants_turn(&mut self) { + self.pending = Some("react".into()); + } + pub fn wake(&mut self, t: f64) { + if !self.state.asleep || self.state.pet.is_none() { + return; + } + self.state.asleep = false; + self.state.woke_at = t; + self.disturbance_count = 0; + self.disturbance_last = 0.0; + self.push_line(format!( + "* {} woke up *", + self.state.pet.as_ref().unwrap().name + )); + } + pub fn disturb(&mut self, kind: &str, t: f64) { + if !self.state.asleep { + return; + } + if t - self.disturbance_last >= 5.0 * MIN { + self.disturbance_count = 0; + } + self.disturbance_count += 1; + self.disturbance_last = t; + if rand::random::() + < world::wake_chance(self.state.stats.energy, kind, self.disturbance_count) + { + self.wake(t); + } + } + pub fn sleep_effects(&mut self, t: f64) { + self.disturbance_count = 0; + self.disturbance_last = 0.0; + self.state.lights_off |= world::part_of_day(date(&self.state, t).hour()) == "night"; + self.effect("sleep", ""); + } + pub fn advance(&mut self, t: f64, seed: &str) { + if self.authority != "remote" { + return; + } + let from = self.state.last_tick; + if t <= from { + return; + } + let quiet = t - from > 2.0 * MIN; + let events = simulate_body( + &mut self.state, + from, + t.min(from + 7.0 * DAY), + quiet, + &mut rand::random::, + ); + self.state.last_tick = t; + self.roll_day(t, seed); + let Some(pet) = self.state.pet.clone() else { + return; + }; + let stage = stage_for(t - pet.hatched); + if pet.stage != stage { + self.state.pet.as_mut().unwrap().stage = stage.into(); + self.push_line(format!("* {} grew into a {stage}! *", pet.name)); + self.wants_turn(); + } + if events.iter().any(|e| e == "pooped") { + self.log("pooped".into(), t, seed); + if !quiet { + self.push_line(format!("* {} pooped *", pet.name)); + self.wants_turn(); + } + } + if events.iter().any(|e| e == "fell asleep") { + self.log("napped".into(), t, seed); + self.sleep_effects(t); + } + if events.iter().any(|e| e == "woke") && !self.state.asleep { + self.disturbance_count = 0; + self.disturbance_last = 0.0; + self.push_line(format!("* {} woke up *", pet.name)); + self.wants_turn(); + } + if self.state.stats.tummy < 15.0 + && !self + .state + .today + .log + .log + .iter() + .any(|s| s == "was very hungry") + { + self.log("was very hungry".into(), t, seed); + } + if self.state.stats.clean < 20.0 + && !self.state.today.log.log.iter().any(|s| s == "was stinky") + { + self.log("was stinky".into(), t, seed); + } + if self + .game + .as_ref() + .is_some_and(|g| t - g.started > 5.0 * MIN) + { + self.game = None; + } + self.revision += 1; + } + pub fn world(&self, t: f64, seed: &str) -> WorldState { + let p = self.state.pet.as_ref().unwrap(); + let d = date(&self.state, t); + let yesterday = self + .state + .diary + .iter() + .rev() + .find(|p| p.date != self.state.today.date) + .map(|p| { + let first = p + .text + .char_indices() + .find(|(i, c)| { + ['.', '!', '?'].contains(c) + && p.text[*i + 1..] + .chars() + .next() + .is_some_and(char::is_whitespace) + }) + .map_or(p.text.as_str(), |(i, _)| &p.text[..=i]); + let first: String = world::compact(first).chars().take(140).collect(); + if first.to_lowercase().starts_with("dear diary") { + first + } else { + format!("dear diary, {first}") + } + }) + .unwrap_or_default(); + WorldState { + pet: p.name.clone(), + owner: self.state.owner.clone(), + stage: p.stage.clone(), + day: day_num(&self.state, t), + traits: p.traits.clone(), + hh: d.hour(), + mm: d.minute(), + weather: weather_at(&self.state, t, seed), + stats: self.state.stats.clone(), + memories: self.state.memories.clone(), + today: self.state.today.log.log.clone(), + shelf: self.state.shelf.clone(), + toy: p.toy.clone(), + poop: self.state.poop, + asleep: self.state.asleep, + sick: self.state.sick, + yesterday, + } + } + pub fn command(&mut self, command: Command, t: f64, seed: &str) -> Result<()> { + ensure!(self.authority == "remote", "this pet is running locally"); + self.advance(t, seed); + self.state.last_active = t; + self.last_seen = t; + match command { + Command::Hatch { name } => { + ensure!( + self.state.pet.is_none() && self.state.egg.is_some(), + "there is no egg to hatch" + ); + let name = clean_name(&name, 16); + ensure!(!name.is_empty(), "pet name is required"); + use rand::seq::SliceRandom; + let mut traits = world::PERSONALITIES.to_vec(); + traits.shuffle(&mut rand::rng()); + self.state.pet = Some(Pet { + name, + traits: traits[..2].iter().map(|s| s.to_string()).collect(), + color: pick(&["cream", "pink", "mint", "butter", "lavender", "sky"]), + toy: pick(world::TOYS), + hatched: t, + stage: "baby".into(), + }); + self.state.egg = None; + self.state.today = Today { + date: date_key(&self.state, t), + log: DayLog { + log: vec![], + weather: Some(weather_at(&self.state, t, seed)), + day: Some(1), + }, + }; + self.state.next_poop = t + 3.0 * HOUR; + self.effect("sparkle", ""); + self.wants_turn(); + } + Command::Message { text } => { + ensure!( + self.state.pet.is_some() && self.game.is_none(), + "pet is unavailable" + ); + let text: String = + world::compact(&text.replace(['\n', '[', ']', '{', '}', '*'], " ")) + .chars() + .take(200) + .collect(); + ensure!(!text.is_empty(), "message is empty"); + self.disturb("talk", t); + self.push_line(format!("{}: {text}", self.state.owner)); + if text.len() > 12 && rand::random::() < 0.5 { + self.log( + format!( + "{} said: {}", + self.state.owner, + text.chars().take(48).collect::().to_lowercase() + ), + t, + seed, + ); + } + self.wants_turn(); + } + Command::Care { kind, item } => { + ensure!( + self.state.pet.is_some() && self.game.is_none(), + "pet is unavailable" + ); + let owner = self.state.owner.clone(); + let pet = self.state.pet.as_ref().unwrap().name.clone(); + match kind.as_str() { + "feed" | "bath" | "medicine" | "play" => { + ensure!( + self.state.offer.is_none(), + "pet is still deciding about an offer" + ); + if kind == "feed" { + ensure!( + item.as_ref() + .is_some_and(|f| world::FOODS.contains(&f.as_str())), + "unknown food" + ); + } + let line = match kind.as_str() { + "feed" => format!( + "* {owner} offered {pet} {} *", + world::a(item.as_ref().unwrap()) + ), + "bath" => format!("* {owner} offered {pet} a bath *"), + "medicine" => format!("* {owner} offered {pet} medicine *"), + _ => format!("* {owner} asked {pet} to play catch *"), + }; + self.state.offer = Some(Offer { kind, item }); + self.push_line(line); + } + "pat" => { + self.disturb("touch", t); + self.effect("pat", ""); + self.state.stats.fun = clamp(self.state.stats.fun + 4.0); + self.state.counts.pats += 1; + let line = format!("* {owner} patted {pet} *"); + if self.state.chat.last() != Some(&line) { + self.push_line(line); + self.log("got patted".into(), t, seed); + } + } + "clean" => { + if self.state.poop > 0 { + self.state.poop = 0; + self.state.stats.clean = clamp(self.state.stats.clean + 10.0); + self.effect("sparkle", ""); + self.push_line(format!("* {owner} cleaned up the poop *")); + } + } + "lights" => { + self.state.lights_off = !self.state.lights_off; + self.push_line(format!( + "* {owner} turned {} the lights *", + if self.state.lights_off { "off" } else { "on" } + )); + if !self.state.lights_off { + self.disturb("light", t); + } + } + _ => anyhow::bail!("unknown care action"), + } + self.wants_turn(); + } + Command::Rename { name } => { + let name = clean_name(&name, 24); + ensure!(!name.is_empty(), "owner name is required"); + self.state.owner = name; + } + Command::Chatter { value } => { + self.state.settings.chatter = value; + self.schedule_idle(t); + } + Command::ForgetMemory { index, fact } => { + ensure!( + self.state.memories.get(index) == Some(&fact), + "memory changed; refresh before forgetting it" + ); + self.state.memories.remove(index); + } + Command::GameComplete { game_id, score } => { + ensure!( + self.game.as_ref().is_some_and(|g| g.id == game_id), + "game no longer exists" + ); + ensure!(score <= 60, "invalid game score"); + self.complete_game(score, t, seed)?; + } + } + self.context_revision += 1; + self.revision += 1; + Ok(()) + } + pub fn complete_game(&mut self, n: u32, t: f64, seed: &str) -> Result<()> { + ensure!(self.game.take().is_some(), "no active game"); + let pet = self.state.pet.as_ref().unwrap().name.clone(); + self.state.stats.fun = clamp(self.state.stats.fun + 8.0 + n as f64 * 3.0); + self.state.stats.energy = clamp(self.state.stats.energy - 8.0); + self.state.counts.games += 1; + self.push_line(format!( + "* {} played catch with {pet} ({n} {}) *", + self.state.owner, + if n == 1 { "star" } else { "stars" } + )); + self.log( + format!( + "played catch ({n} {})", + if n == 1 { "star" } else { "stars" } + ), + t, + seed, + ); + self.wants_turn(); + Ok(()) + } + pub fn answer_offer(&mut self, yes: bool, t: f64, seed: &str) { + let Some(offer) = self.state.offer.take() else { + return; + }; + let thing = match offer.kind.as_str() { + "feed" => world::a(offer.item.as_deref().unwrap_or("")), + "bath" => "a bath".into(), + "medicine" => "medicine".into(), + _ => "catch".into(), + }; + if !yes { + self.log(format!("said no to {thing}"), t, seed); + return; + } + self.wake(t); + match offer.kind.as_str() { + "feed" => { + let food = offer.item.unwrap(); + self.state.stats.tummy = clamp( + self.state.stats.tummy + + if ["pudding", "cookie"].contains(&food.as_str()) { + 22.0 + } else { + 28.0 + }, + ); + if ["pudding", "cookie", "strawberry"].contains(&food.as_str()) { + self.state.stats.fun = clamp(self.state.stats.fun + 6.0); + } + self.state.counts.fed += 1; + self.effect("eat", &food); + self.log(format!("ate {}", world::a(&food)), t, seed); + } + "bath" => { + self.state.stats.clean = 100.0; + self.state.stats.fun = clamp(self.state.stats.fun - 2.0); + self.state.counts.baths += 1; + self.effect("bath", ""); + self.log("had a bath".into(), t, seed); + } + "medicine" => { + if self.state.sick { + self.state.sick = false; + self.state.grime_since = 0.0; + self.effect("sparkle", ""); + } + self.log("took medicine".into(), t, seed); + } + "play" => { + self.game = Some(Game { + id: uuid::Uuid::new_v4().to_string(), + started: t, + }); + } + _ => {} + } + } + pub fn apply_line(&mut self, body: &str, t: f64, seed: &str) { + let sleeping = self.state.asleep; + let tags: Vec<_> = world::TAG + .captures_iter(body) + .map(|c| { + ( + c[1].to_owned(), + c.get(2).map_or("", |m| m.as_str()).to_owned(), + c.get(3).is_some(), + ) + }) + .collect(); + let answers: Vec<_> = tags + .iter() + .filter(|(name, _, _)| world::ANSWER_TOOLS.contains(&name.as_str())) + .collect(); + if self.state.offer.is_some() + && answers.len() == 1 + && answers[0].1.is_empty() + && !answers[0].2 + { + self.answer_offer(answers[0].0 == "yes", t, seed); + } + for (name, arg, result) in tags { + if result && world::EFFECT_TOOLS.contains(&name.as_str()) { + continue; + } + if name == "dream" { + if sleeping && self.state.asleep && world::DREAMS.contains(&arg.as_str()) { + self.effect("dream", &arg); + let entry = format!("dreamed of {}", world::dream_thing(&arg)); + if !self.state.today.log.log.contains(&entry) { + let old: Vec<_> = self + .state + .today + .log + .log + .iter() + .filter(|s| s.starts_with("dreamed of ")) + .cloned() + .collect(); + if old.len() >= 2 { + self.state.today.log.log.retain(|s| *s != old[0]); + } + self.log(entry, t, seed); + } + } + continue; + } + if sleeping || self.state.asleep { + continue; + } + match name.as_str() { + "emote" if world::EMOTES.contains(&arg.as_str()) => self.effect("emote", &arg), + "want" if world::WANTS.contains(&arg.as_str()) => self.effect("want", &arg), + "walk" if world::WALKS.contains(&arg.as_str()) => self.effect("walk", &arg), + "remember" if !arg.trim().is_empty() => { + let fact = arg.trim().to_owned(); + if !self + .state + .memories + .iter() + .any(|s| s.to_lowercase() == fact.to_lowercase()) + { + self.state.memories.push(fact.clone()); + if self.state.memories.len() > 60 { + self.state.memories.remove(0); + } + self.log(format!("learned: {fact}"), t, seed); + } + } + "gift" if world::GIFTS.contains(&arg.as_str()) => { + self.state.shelf.push(arg.clone()); + if self.state.shelf.len() > 24 { + self.state.shelf.remove(0); + } + self.effect("gift", &arg); + self.log(format!("gave {} a {arg}", self.state.owner), t, seed); + } + "diary" if !arg.is_empty() => self.log(format!("note: {}", arg.trim()), t, seed), + "sleep" if arg.is_empty() => { + self.state.asleep = true; + self.log("napped".into(), t, seed); + self.sleep_effects(t); + } + _ => {} + } + } + if self.state.offer.is_some() { + self.answer_offer(false, t, seed); + } + self.revision += 1; + } + pub fn schedule_idle(&mut self, t: f64) { + self.next_idle = t + if self.state.asleep { + (3.0 + rand::random::() * 3.0) * MIN + } else if self.state.settings.chatter { + 8000.0 + rand::random::() * 12000.0 + } else { + (2.0 + rand::random::() * 2.0) * MIN + }; + } +} diff --git a/server/src/store.rs b/server/src/store.rs new file mode 100644 index 0000000..1386ec2 --- /dev/null +++ b/server/src/store.rs @@ -0,0 +1,253 @@ +//! Private saves and atomic, epoch-fenced operations. Access keys stay hashed. +use crate::simulation::HostedPet; +use anyhow::{Context, Result, ensure}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use subtle::ConstantTimeEq; + +#[derive(Debug)] +pub struct AccessDenied; +impl std::fmt::Display for AccessDenied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid pet access key") + } +} +impl std::error::Error for AccessDenied {} +#[derive(Debug)] +pub struct Conflict(pub &'static str); +impl std::fmt::Display for Conflict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::error::Error for Conflict {} + +pub struct Store { + conn: Connection, +} +pub fn hash(text: &str) -> Vec { + Sha256::digest(text.as_bytes()).to_vec() +} +fn authorized(expected: &[u8], key: &str) -> bool { + key.len() == 64 + && key.bytes().all(|b| b.is_ascii_hexdigit()) + && bool::from(expected.ct_eq(&hash(key))) +} +fn load(conn: &Connection, id: &str) -> Result, HostedPet)>> { + let raw: Option<(Vec, String)> = conn + .query_row("SELECT key_hash, state FROM pets WHERE id=?1", [id], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .optional()?; + raw.map(|(key, state)| { + let row: HostedPet = serde_json::from_str(&state) + .context("stored pet is unreadable; it has not been replaced")?; + row.state.validate()?; + ensure!( + matches!(row.authority.as_str(), "local" | "remote"), + "stored execution owner is invalid" + ); + Ok((key, row)) + }) + .transpose() +} +fn update(conn: &Connection, id: &str, row: &HostedPet) -> Result<()> { + conn.execute( + "UPDATE pets SET state=?2 WHERE id=?1", + params![id, serde_json::to_string(row)?], + )?; + Ok(()) +} +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(path)?; + let version: u32 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + ensure!( + version <= 1, + "database was created by a newer host; it has not been changed" + ); + if version == 0 { + let tables: u32 = conn.query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table'", + [], + |r| r.get(0), + )?; + ensure!( + tables == 0, + "database has an unrecognized schema; choose a separate database" + ); + } + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", true)?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS pets(id TEXT PRIMARY KEY, key_hash BLOB NOT NULL, state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS operations(pet_id TEXT NOT NULL REFERENCES pets(id) ON DELETE CASCADE, id TEXT NOT NULL, body_hash BLOB NOT NULL, created INTEGER NOT NULL, PRIMARY KEY(pet_id,id)); PRAGMA user_version=1;")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for candidate in [ + path.to_path_buf(), + std::path::PathBuf::from(format!("{}-wal", path.display())), + std::path::PathBuf::from(format!("{}-shm", path.display())), + ] { + if candidate.exists() { + std::fs::set_permissions(candidate, std::fs::Permissions::from_mode(0o600))?; + } + } + } + Ok(Self { conn }) + } + pub fn create( + &mut self, + id: &str, + key: &str, + row: HostedPet, + max_pets: usize, + ) -> Result { + ensure!( + uuid::Uuid::parse_str(id)?.to_string() == id, + "invalid pet identifier" + ); + ensure!( + key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid access key" + ); + row.state.validate()?; + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some((expected, existing)) = load(&tx, id)? { + if !authorized(&expected, key) { + return Err(AccessDenied.into()); + } + // Retrying creation must not replace a running pet or its save. + tx.commit()?; + return Ok(existing); + } + let count: i64 = tx.query_row("SELECT count(*) FROM pets", [], |r| r.get(0))?; + ensure!( + count < max_pets as i64, + "this host has reached its pet limit" + ); + tx.execute( + "INSERT INTO pets(id,key_hash,state) VALUES (?1,?2,?3)", + params![id, hash(key), serde_json::to_string(&row)?], + )?; + tx.commit()?; + Ok(row) + } + pub fn get(&self, id: &str, key: &str) -> Result { + let Some((expected, row)) = load(&self.conn, id)? else { + return Err(AccessDenied.into()); + }; + if !authorized(&expected, key) { + return Err(AccessDenied.into()); + } + Ok(row) + } + pub fn operation( + &mut self, + id: &str, + key: &str, + epoch: u64, + operation: &str, + body: &str, + f: impl FnOnce(&mut HostedPet) -> Result<()>, + ) -> Result { + ensure!( + uuid::Uuid::parse_str(operation)?.to_string() == operation, + "invalid operation identifier" + ); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some((expected, mut row)) = load(&tx, id)? else { + return Err(AccessDenied.into()); + }; + if !authorized(&expected, key) { + return Err(AccessDenied.into()); + } + let old: Option> = tx + .query_row( + "SELECT body_hash FROM operations WHERE pet_id=?1 AND id=?2", + params![id, operation], + |r| r.get(0), + ) + .optional()?; + if let Some(old) = old { + if old != hash(body) { + return Err(Conflict("operation ID was reused for different data").into()); + } + tx.commit()?; + return Ok(row); + } + if row.epoch != epoch { + return Err(Conflict("execution changed; refresh this pet before acting").into()); + } + f(&mut row)?; + update(&tx, id, &row)?; + tx.execute( + "INSERT INTO operations(pet_id,id,body_hash,created) VALUES(?1,?2,?3,?4)", + params![ + id, + operation, + hash(body), + chrono::Utc::now().timestamp_millis() + ], + )?; + tx.commit()?; + Ok(row) + } + pub fn delete(&mut self, id: &str, key: &str) -> Result<()> { + self.get(id, key)?; + self.conn.execute("DELETE FROM pets WHERE id=?1", [id])?; + Ok(()) + } + pub fn advance(&mut self, t: f64, seed: &str) -> Result> { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let raw: Vec<(String, String)> = { + let mut s = tx.prepare("SELECT id,state FROM pets")?; + s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()? + }; + let mut rows = Vec::new(); + for (id, raw) in raw { + let mut row: HostedPet = + serde_json::from_str(&raw).context("stored pet is unreadable")?; + row.state.validate()?; + if row.authority == "remote" && t - row.state.last_tick >= 1000.0 { + row.advance(t, seed); + update(&tx, &id, &row)?; + } + rows.push((id, row)); + } + tx.commit()?; + Ok(rows) + } + pub fn commit( + &mut self, + id: &str, + epoch: u64, + context: u64, + f: impl FnOnce(&mut HostedPet), + ) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some((_, mut row)) = load(&tx, id)? else { + return Ok(false); + }; + if row.epoch != epoch || row.context_revision != context || row.authority != "remote" { + return Ok(false); + } + f(&mut row); + update(&tx, id, &row)?; + tx.commit()?; + Ok(true) + } +} diff --git a/server/src/types.rs b/server/src/types.rs new file mode 100644 index 0000000..5cddc46 --- /dev/null +++ b/server/src/types.rs @@ -0,0 +1,204 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Hosting { + pub mode: String, + pub brain_id: Option, + pub time_zone: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Stats { + pub tummy: f64, + pub fun: f64, + pub energy: f64, + pub clean: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Pet { + pub name: String, + pub traits: Vec, + pub color: String, + pub toy: String, + pub hatched: f64, + pub stage: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Egg { + pub laid: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DayLog { + pub log: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub weather: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub day: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Today { + pub date: String, + #[serde(flatten)] + pub log: DayLog, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DiaryPage { + pub date: String, + pub day: i64, + pub weather: String, + pub stage: String, + pub text: String, + pub doodles: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Offer { + pub kind: String, + pub item: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Settings { + pub chatter: bool, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Counts { + pub fed: u64, + pub pats: u64, + pub games: u64, + pub baths: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PetState { + pub v: u32, + pub created: f64, + pub owner: String, + pub pet: Option, + pub egg: Option, + pub stats: Stats, + pub sick: bool, + pub poop: u32, + pub asleep: bool, + pub lights_off: bool, + pub memories: Vec, + pub shelf: Vec, + pub chat: Vec, + pub diary: Vec, + pub today: Today, + pub history: BTreeMap, + pub offer: Option, + pub woke_at: f64, + pub last_tick: f64, + pub last_active: f64, + pub grime_since: f64, + pub next_poop: f64, + pub settings: Settings, + pub counts: Counts, + pub hosting: Hosting, + #[serde(flatten)] + pub extra: Map, +} + +impl PetState { + pub fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!(self.v == 2, "this host reads save version 2"); + anyhow::ensure!( + matches!(self.hosting.mode.as_str(), "local" | "remote"), + "invalid execution mode" + ); + self.hosting.time_zone.parse::()?; + anyhow::ensure!( + self.owner.chars().count() <= 24 + && !self.owner.contains(['\n', ':', '[', ']', '{', '}', '*']), + "invalid owner name" + ); + for v in [ + self.stats.tummy, + self.stats.fun, + self.stats.energy, + self.stats.clean, + ] { + anyhow::ensure!((0.0..=100.0).contains(&v), "invalid need level"); + } + anyhow::ensure!(self.poop <= 4, "invalid poop count"); + for t in [ + self.created, + self.last_tick, + self.last_active, + self.next_poop, + self.woke_at, + self.grime_since, + ] { + anyhow::ensure!((0.0..=8.64e15).contains(&t), "invalid save timestamp"); + } + for count in [ + self.counts.fed, + self.counts.pats, + self.counts.games, + self.counts.baths, + ] { + anyhow::ensure!(count <= 1_000_000_000_000, "invalid care count"); + } + anyhow::ensure!( + self.chat.len() <= 240 && self.memories.len() <= 60 && self.shelf.len() <= 24, + "save exceeds history limits" + ); + if let Some(pet) = &self.pet { + anyhow::ensure!( + !pet.name.is_empty() + && pet.name.chars().count() <= 16 + && !pet.name.contains(['\n', ':', '[', ']', '{', '}', '*']), + "invalid pet name" + ); + anyhow::ensure!( + crate::world::STAGES.contains(&pet.stage.as_str()), + "invalid pet stage" + ); + anyhow::ensure!( + pet.traits + .iter() + .all(|t| crate::world::PERSONALITIES.contains(&t.as_str())), + "invalid personality" + ); + anyhow::ensure!( + crate::world::TOYS.contains(&pet.toy.as_str()), + "invalid toy" + ); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct WorldState { + pub pet: String, + pub owner: String, + pub stage: String, + pub day: i64, + pub traits: Vec, + pub hh: u32, + pub mm: u32, + pub weather: String, + pub stats: Stats, + pub memories: Vec, + pub today: Vec, + pub shelf: Vec, + pub toy: String, + pub poop: u32, + pub asleep: bool, + pub sick: bool, + pub yesterday: String, +} diff --git a/server/src/world.rs b/server/src/world.rs new file mode 100644 index 0000000..6c54a48 --- /dev/null +++ b/server/src/world.rs @@ -0,0 +1,267 @@ +//! The trained prompt contract, mirrored by web/world.ts and train/world.py. +use crate::types::{Stats, WorldState}; +use regex::Regex; +use std::{collections::HashSet, sync::LazyLock}; + +pub const STAGES: &[&str] = &["baby", "kid", "teen", "adult"]; +pub const PERSONALITIES: &[&str] = &[ + "bouncy", "shy", "curious", "sleepy", "dramatic", "foodie", "silly", "sweet", "grumpy", + "brave", "artsy", "dreamy", "chatty", "clingy", "sassy", "polite", +]; +pub const FOODS: &[&str] = &[ + "strawberry", + "cookie", + "rice ball", + "carrot", + "pudding", + "apple", +]; +pub const GIFTS: &[&str] = &[ + "flower", "pebble", "star", "leaf", "shell", "heart", "drawing", "feather", +]; +pub const DREAMS: &[&str] = &[ + "strawberry", + "cookie", + "rice ball", + "carrot", + "pudding", + "apple", + "flower", + "pebble", + "star", + "leaf", + "shell", + "heart", + "drawing", + "feather", + "ball", + "moon", + "sun", + "cloud", + "rain", + "frog", + "cat", + "ghost", + "music", + "sparkle", +]; +pub const EMOTES: &[&str] = &[ + "happy", "love", "sad", "sleepy", "wave", "dance", "spin", "blush", "grumpy", "wow", "giggle", + "cry", +]; +pub const WANTS: &[&str] = &["snack", "play", "bath", "sleep", "pats", "medicine"]; +pub const WALKS: &[&str] = &["left", "right"]; +pub const DOODLES: &[&str] = &[ + "heart", + "star", + "strawberry", + "cloud", + "moon", + "sun", + "flower", + "cookie", + "frog", + "rain", + "music", + "cat", + "ghost", + "sparkle", +]; +pub const WEATHER_DAY: &[&str] = &["sunny", "cloudy", "rainy", "windy", "snowy", "foggy"]; +pub const WEATHER_NIGHT: &[&str] = &["clear", "cloudy", "rainy", "windy", "snowy", "starry"]; +pub const TOYS: &[&str] = &["ball", "yarn", "tiny drum", "rubber duck", "blocks"]; +pub const EFFECT_TOOLS: &[&str] = &[ + "emote", "want", "remember", "gift", "sleep", "dream", "diary", "walk", "yes", "no", +]; +pub const ANSWER_TOOLS: &[&str] = &["yes", "no"]; +pub const LOOKUP_TOOLS: &[&str] = &["clock", "recall", "look", "yesterday"]; +pub static TAG: LazyLock = LazyLock::new(|| { + Regex::new(r"\[([A-Za-z0-9_]+)(?::([^\]\n]{1,80}))?\](\{[^}\n]*\})?").unwrap() +}); +pub static NOT_CUTE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i-u)\b(sex\w*|porn\w*|nude\w*|naked|panties|penis|vagina|cock|dick|pussy|boob\w*|tits?|fuck\w*|shit\w*|bitch\w*|cunt|whore|slut|rape\w*|nigg\w*|fag\w*|retard\w*|kill (you|yourself)|suicide)\b").unwrap() +}); +static WORD: LazyLock = LazyLock::new(|| Regex::new(r"[a-z0-9']+").unwrap()); +static SPACES: LazyLock = LazyLock::new(|| Regex::new(r"\s+").unwrap()); + +pub fn part_of_day(h: u32) -> &'static str { + match h { + 5..=11 => "morning", + 12..=16 => "afternoon", + 17..=20 => "evening", + _ => "night", + } +} +pub fn level_words(s: &Stats) -> [&'static str; 4] { + fn word(v: f64, words: &[&'static str]) -> &'static str { + words[((v.round() * words.len() as f64 / 101.0).floor() as usize).min(words.len() - 1)] + } + [ + word( + s.tummy, + &["starving", "hungry", "peckish", "full", "stuffed"], + ), + word(s.fun, &["sad", "bored", "okay", "happy", "giddy"]), + word(s.energy, &["exhausted", "sleepy", "okay", "awake", "hyper"]), + word(s.clean, &["stinky", "messy", "clean", "sparkly"]), + ] +} +pub fn header(kind: &str, st: &WorldState) -> String { + let w = level_words(&st.stats); + let mut lines = vec![ + format!("## tokenling {kind}"), + format!( + "pet: {} · {} · day {} · {}", + st.pet, + st.stage, + st.day, + st.traits.join(", ") + ), + format!("owner: {}", st.owner), + ]; + if kind == "chat" { + let mut now = format!( + "now: {:02}:{:02} {}, {}", + st.hh, + st.mm, + part_of_day(st.hh), + st.weather + ); + if st.asleep { + now += &format!(", {} is asleep", st.pet); + } + if st.sick { + now += &format!(", {} is sick", st.pet); + } + lines.push(now); + lines.push(format!( + "needs: tummy {}, fun {}, energy {}, {}", + w[0], w[1], w[2], w[3] + )); + } else { + lines.push(format!("weather: {}", st.weather)); + } + if !st.memories.is_empty() { + lines.push(format!( + "remembers: {}", + st.memories[st.memories.len().saturating_sub(3)..].join("; ") + )); + } + let today: Vec<_> = st + .today + .iter() + .filter(|t| kind != "chat" || !t.starts_with(&format!("{} said: ", st.owner))) + .cloned() + .collect(); + if !today.is_empty() { + lines.push(format!( + "today: {}", + today[today.len().saturating_sub(6)..].join("; ") + )); + } + lines.push("---".into()); + lines.join("\n") + "\n" +} +pub fn words(text: &str) -> HashSet { + let stop: HashSet<&str> = [ + "the", "and", "you", "your", "are", "was", "has", "have", "for", "with", "what", "who", + "does", "did", "about", "that", "this", "like", "likes", "she", "her", "his", "him", + "they", "them", "named", "name", "is", + ] + .into_iter() + .collect(); + WORD.find_iter(&text.to_lowercase()) + .filter_map(|m| { + let mut word = m.as_str().trim_matches('\'').to_owned(); + if word.len() < 3 || stop.contains(word.as_str()) { + return None; + } + if word.ends_with('s') && word.len() > 3 { + word.pop(); + } + Some(word) + }) + .collect() +} +pub fn recall(memories: &[String], query: &str) -> String { + let q = words(query); + let hits: Vec<_> = memories + .iter() + .rev() + .filter(|m| words(m).iter().any(|w| q.contains(w))) + .take(2) + .cloned() + .collect(); + if hits.is_empty() { + "nothing".into() + } else { + hits.join("; ") + } +} +pub fn tool_result(st: &WorldState, name: &str, arg: &str) -> Option { + match name { + "clock" => Some(format!("{:02}:{:02} {}", st.hh, st.mm, part_of_day(st.hh))), + "look" => Some(format!( + "shelf: {} · toy: {} · window: {}{}", + if st.shelf.is_empty() { + "empty".into() + } else { + st.shelf[st.shelf.len().saturating_sub(4)..].join(", ") + }, + st.toy, + st.weather, + if st.poop > 0 { + format!(" · poop x{}", st.poop) + } else { + String::new() + } + )), + "recall" => Some(recall(&st.memories, arg)), + "yesterday" => Some(if st.yesterday.is_empty() { + "no diary yet".into() + } else { + st.yesterday.clone() + }), + _ => None, + } +} +pub fn a(food: &str) -> String { + format!( + "{} {food}", + if food.starts_with(['a', 'e', 'i', 'o', 'u']) { + "an" + } else { + "a" + } + ) +} +pub fn dream_thing(item: &str) -> String { + match item { + "music" | "rain" => item.into(), + "moon" | "sun" => format!("the {item}"), + _ => a(item), + } +} +pub fn wake_chance(energy: f64, kind: &str, n: u32) -> f64 { + let base = match kind { + "talk" => 0.03, + "touch" => 0.1, + _ => 0.2, + }; + (base + 0.007 * energy + 0.12 * (n as f64 - 1.0)).clamp(0.0, 0.9) +} +pub fn clean_text(text: &str) -> String { + SPACES + .replace_all( + &TAG.replace_all(text, " ").replace(['[', ']', '{', '}'], ""), + " ", + ) + .trim() + .to_owned() +} +pub fn silent(text: &str) -> bool { + !clean_text(text).bytes().any(|b| b.is_ascii_alphabetic()) +} +pub fn compact(text: &str) -> String { + SPACES.replace_all(text, " ").trim().to_owned() +} diff --git a/server/tests/host.rs b/server/tests/host.rs new file mode 100644 index 0000000..b6ae5c6 --- /dev/null +++ b/server/tests/host.rs @@ -0,0 +1,517 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use tokenling_server::{ + brain::{Generated, Generation}, + host::{Generator, Host, Worker}, + simulation::{self, Command, HOUR, HostedPet, MIN}, + store::Store, + types::PetState, +}; +use tower::ServiceExt; + +fn pet() -> PetState { + let mut value: Value = + serde_json::from_str(include_str!("../../test/fixtures/save-v1.json")).unwrap(); + value["v"] = json!(2); + value["hosting"] = json!({"mode":"local","brainId":"test-brain","timeZone":"UTC"}); + value["lastTick"] = json!(simulation::now()); + value["nextPoop"] = json!(simulation::now() + 10.0 * HOUR); + serde_json::from_value(value).unwrap() +} +fn activate(row: &mut HostedPet) { + row.authority = "remote".into(); + row.epoch += 1; + row.state.hosting.mode = "remote".into(); + row.pending = Some("react".into()); +} + +#[test] +fn handoffs_fence_old_replies_and_keep_the_complete_save() { + let directory = tempfile::tempdir().unwrap(); + let mut db = Store::open(&directory.path().join("state.sqlite")).unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let key = "a".repeat(64); + let original = pet(); + db.create( + &id, + &key, + HostedPet::new(original.clone(), simulation::now()), + 64, + ) + .unwrap(); + let operation = uuid::Uuid::new_v4().to_string(); + let remote = db + .operation(&id, &key, 0, &operation, "activate", |r| { + activate(r); + Ok(()) + }) + .unwrap(); + let captured = (remote.epoch, remote.context_revision); + let local = db + .operation( + &id, + &key, + 1, + &uuid::Uuid::new_v4().to_string(), + "local", + |r| { + r.authority = "local".into(); + r.epoch += 1; + r.state.hosting.mode = "local".into(); + Ok(()) + }, + ) + .unwrap(); + assert!( + !db.commit(&id, captured.0, captured.1, |r| r + .state + .shelf + .push("flower".into())) + .unwrap() + ); + assert_eq!(local.state.chat, original.chat); + assert_eq!(local.state.memories, original.memories); + assert_eq!( + serde_json::to_value(local.state.diary).unwrap(), + serde_json::to_value(original.diary).unwrap() + ); + assert_eq!(db.get(&id, &key).unwrap().state.shelf, original.shelf); + assert!( + db.operation( + &id, + &key, + 1, + &uuid::Uuid::new_v4().to_string(), + "stale", + |_| Ok(()) + ) + .is_err() + ); +} + +#[test] +fn retries_do_not_repeat_care_or_overwrite_a_running_pet() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite"); + let mut db = Store::open(&path).unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let key = "b".repeat(64); + let state = pet(); + db.create( + &id, + &key, + HostedPet::new(state.clone(), simulation::now()), + 64, + ) + .unwrap(); + db.operation( + &id, + &key, + 0, + &uuid::Uuid::new_v4().to_string(), + "activate", + |r| { + activate(r); + Ok(()) + }, + ) + .unwrap(); + let op = uuid::Uuid::new_v4().to_string(); + let t = simulation::now(); + let count = db.get(&id, &key).unwrap().state.counts.pats; + for _ in 0..2 { + db.operation(&id, &key, 1, &op, "pat", |r| { + r.command( + Command::Care { + kind: "pat".into(), + item: None, + }, + t, + "", + ) + }) + .unwrap(); + } + assert_eq!(db.get(&id, &key).unwrap().state.counts.pats, count + 1); + assert!( + db.operation(&id, &key, 1, &op, "different payload", |_| Ok(())) + .is_err() + ); + let retried = db + .create(&id, &key, HostedPet::new(state, simulation::now()), 64) + .unwrap(); + assert_eq!(retried.state.counts.pats, count + 1); + assert_eq!(retried.authority, "remote"); + drop(db); + let reopened = Store::open(&path).unwrap(); + assert_eq!( + reopened.get(&id, &key).unwrap().state.counts.pats, + count + 1 + ); + assert!( + !std::fs::read(&path) + .unwrap() + .windows(64) + .any(|w| w == key.as_bytes()) + ); +} + +#[test] +fn sleeping_offers_are_brain_decisions_and_unknown_tags_have_no_effect() { + let t = simulation::now(); + let mut row = HostedPet::new(pet(), t); + activate(&mut row); + row.state.asleep = true; + row.state.stats.energy = 50.0; + row.command( + Command::Care { + kind: "feed".into(), + item: Some("cookie".into()), + }, + t, + "", + ) + .unwrap(); + assert!(row.state.asleep); + let tummy = row.state.stats.tummy; + row.apply_line("[dream:cookie] [no] [gift:flower]", t, ""); + assert!(row.state.asleep); + assert_eq!(row.state.stats.tummy, tummy); + assert!(row.state.offer.is_none()); + row.command( + Command::Care { + kind: "feed".into(), + item: Some("cookie".into()), + }, + t, + "", + ) + .unwrap(); + row.apply_line("yes [yes] [teleport:kitchen]", t, ""); + assert!(!row.state.asleep); + assert_eq!(row.state.stats.tummy, (tummy + 22.0).min(100.0)); + let shelf = row.state.shelf.clone(); + row.apply_line("[gift:spaceship] [walk:up] [dream:spaceship]", t, ""); + assert_eq!(row.state.shelf, shelf); +} + +#[test] +fn body_rates_and_wake_protection_match_the_browser() { + use chrono::TimeZone; + let t = chrono::Utc + .with_ymd_and_hms(2026, 10, 1, 12, 0, 0) + .unwrap() + .timestamp_millis() as f64; + let mut awake = pet(); + awake.stats = tokenling_server::types::Stats { + tummy: 70.0, + fun: 70.0, + energy: 80.0, + clean: 90.0, + }; + awake.next_poop = t + 10.0 * HOUR; + let mut sleeping = awake.clone(); + sleeping.asleep = true; + sleeping.stats.energy = 40.0; + simulation::simulate_body(&mut awake, t, t + HOUR, false, &mut || 0.5); + simulation::simulate_body(&mut sleeping, t, t + HOUR, false, &mut || 0.5); + assert_eq!( + serde_json::to_value(&awake.stats).unwrap(), + json!({"tummy":62.0,"fun":64.0,"energy":75.0,"clean":87.0}) + ); + assert_eq!( + serde_json::to_value(&sleeping.stats).unwrap(), + json!({"tummy":68.0,"fun":70.0,"energy":62.0,"clean":89.5}) + ); + awake.stats.energy = 5.0; + awake.woke_at = t; + simulation::simulate_body(&mut awake, t, t + MIN, false, &mut || 0.5); + assert!(!awake.asleep); + let events = simulation::simulate_body(&mut awake, t + MIN, t + 6.0 * MIN, false, &mut || 0.5); + assert!(awake.asleep); + assert!(events.iter().any(|e| e == "fell asleep")); +} + +struct Scripted { + calls: Arc, +} +struct BrokenGpu; +impl Generator for BrokenGpu { + fn generate(&mut self, _: &Generation, _: &AtomicBool) -> anyhow::Result { + anyhow::bail!("GPU unavailable") + } +} +#[tokio::test] +async fn failed_gpu_requeues_the_turn_on_cpu_without_applying_it_twice() { + let directory = tempfile::tempdir().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let mut db = Store::open(&directory.path().join("state.sqlite")).unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let key = "e".repeat(64); + let mut row = HostedPet::new(pet(), simulation::now()); + activate(&mut row); + db.create(&id, &key, row, 64).unwrap(); + let host = Arc::new(Host { + db: Mutex::new(db), + model_id: "test-brain".into(), + features: vec![], + max_pets: 64, + weather_seed: String::new(), + cpu: Worker::new( + "cpu", + Scripted { + calls: calls.clone(), + }, + ), + gpu: Some(Worker::new("gpu", BrokenGpu)), + jobs: Mutex::new(Default::default()), + notify: tokio::sync::Notify::new(), + }); + let scheduler = tokio::spawn(host.clone().run()); + for _ in 0..100 { + if host + .db + .lock() + .unwrap() + .get(&id, &key) + .unwrap() + .state + .chat + .iter() + .any(|s| s.contains("[clock]{")) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(!host.gpu.as_ref().unwrap().available.load(Ordering::Relaxed)); + assert!(calls.load(Ordering::Relaxed) >= 2); + let row = host.db.lock().unwrap().get(&id, &key).unwrap(); + assert_eq!( + row.state + .chat + .iter() + .filter(|s| s.contains("[clock]{")) + .count(), + 1 + ); + scheduler.abort(); +} + +#[test] +fn forgetting_a_memory_cannot_delete_a_different_fact_after_eviction() { + let t = simulation::now(); + let mut row = HostedPet::new(pet(), t); + row.state.memories = vec!["Sam likes frogs".into(), "Sam likes flowers".into()]; + activate(&mut row); + row.state.memories.remove(0); + assert!( + row.command( + Command::ForgetMemory { + index: 0, + fact: "Sam likes frogs".into() + }, + t, + "" + ) + .is_err() + ); + assert_eq!(row.state.memories, vec!["Sam likes flowers"]); + row.command( + Command::ForgetMemory { + index: 0, + fact: "Sam likes flowers".into(), + }, + t, + "", + ) + .unwrap(); + assert!(row.state.memories.is_empty()); +} +impl Generator for Scripted { + fn generate(&mut self, g: &Generation, _: &AtomicBool) -> anyhow::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + let (text, reason) = if g.prefix.contains("[clock]{") { + (" hello! [emote:happy]", "line") + } else { + (" [clock]", "lookup") + }; + Ok(Generated { + text: text.into(), + prompt: g.header.clone() + &g.prefix, + reason: reason.into(), + prompt_tokens: 100, + prefill_ms: 1.0, + tps: 100.0, + }) + } +} +async fn request( + app: axum::Router, + method: &str, + path: &str, + key: Option<&str>, + body: Value, +) -> (StatusCode, Value) { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json"); + if let Some(key) = key { + req = req.header("authorization", format!("Bearer {key}")); + } + let res = app + .oneshot( + req.body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let body = to_bytes(res.into_body(), 8 * 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn private_api_runs_background_turns_and_supports_both_transfer_directions() { + let directory = tempfile::tempdir().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let host = Arc::new(Host { + db: Mutex::new(Store::open(&directory.path().join("state.sqlite")).unwrap()), + model_id: "test-brain".into(), + features: vec![], + max_pets: 64, + weather_seed: String::new(), + cpu: Worker::new( + "cpu", + Scripted { + calls: calls.clone(), + }, + ), + gpu: None, + jobs: Mutex::new(Default::default()), + notify: tokio::sync::Notify::new(), + }); + let app = host.clone().router(); + let id = uuid::Uuid::new_v4().to_string(); + let key = "c".repeat(64); + let device = uuid::Uuid::new_v4().to_string(); + let state = pet(); + let prepared_id = uuid::Uuid::new_v4().to_string(); + let prepared_key = "e".repeat(64); + assert_eq!( + request( + app.clone(), + "POST", + "/api/pets", + Some(&prepared_key), + json!({"id":prepared_id,"state":state,"modelId":"test-brain"}) + ) + .await + .0, + StatusCode::OK + ); + let (status, fenced) = request(app.clone(), "POST", &format!("/api/pets/{prepared_id}/transfer"), Some(&prepared_key), + json!({"epoch":0,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"local","deviceId":device,"state":null})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fenced["epoch"], 1); + assert_eq!(fenced["authority"], "local"); + assert_eq!(request(app.clone(), "POST", &format!("/api/pets/{prepared_id}/transfer"), Some(&prepared_key), + json!({"epoch":0,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"remote","deviceId":device,"state":state})).await.0, StatusCode::CONFLICT, + "a late activation cannot take ownership back after a prepared record is parked"); + assert_eq!(request(app.clone(), "POST", &format!("/api/pets/{prepared_id}/transfer"), Some(&prepared_key), + json!({"epoch":1,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"remote","deviceId":uuid::Uuid::new_v4().to_string(),"state":state})).await.0, StatusCode::CONFLICT, + "only the handoff destination can reactivate a parked local copy"); + assert_eq!( + request( + app.clone(), + "POST", + "/api/pets", + None, + json!({"id":id,"state":state,"modelId":"test-brain"}) + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + request( + app.clone(), + "POST", + "/api/pets", + Some(&key), + json!({"id":id,"state":state,"modelId":"test-brain"}) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + request( + app.clone(), + "GET", + &format!("/api/pets/{id}"), + Some(&"d".repeat(64)), + Value::Null + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let (status,remote)=request(app.clone(),"POST",&format!("/api/pets/{id}/transfer"),Some(&key),json!({"epoch":0,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"remote","deviceId":device,"state":state})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(remote["epoch"], 1); + let scheduler = tokio::spawn(host.clone().run()); + for _ in 0..50 { + if host + .db + .lock() + .unwrap() + .get(&id, &key) + .unwrap() + .state + .chat + .iter() + .any(|l| l.contains("[clock]{")) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + calls.load(Ordering::Relaxed) >= 2, + "the brain must run without a browser polling" + ); + let (_, snapshot) = request( + app.clone(), + "GET", + &format!("/api/pets/{id}"), + Some(&key), + Value::Null, + ) + .await; + assert!( + snapshot["state"]["chat"] + .as_array() + .unwrap() + .iter() + .any(|l| l.as_str().unwrap().contains("[clock]{")), + "lookup result is supplied by the host" + ); + let (status,local)=request(app.clone(),"POST",&format!("/api/pets/{id}/transfer"),Some(&key),json!({"epoch":1,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"local","deviceId":device,"state":null})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(local["authority"], "local"); + assert_eq!(local["localDevice"], device); + let (status,resumed)=request(app.clone(),"POST",&format!("/api/pets/{id}/transfer"),Some(&key),json!({"epoch":2,"operationId":uuid::Uuid::new_v4().to_string(),"mode":"remote","deviceId":device,"state":local["state"]})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(resumed["epoch"], 3); + assert_eq!(resumed["authority"], "remote"); + scheduler.abort(); +} diff --git a/server/tests/parity.mjs b/server/tests/parity.mjs new file mode 100644 index 0000000..db4a39f --- /dev/null +++ b/server/tests/parity.mjs @@ -0,0 +1,28 @@ +// Usage: node server/tests/parity.mjs [CONTRACT_BINARY] +// Build tokenling-contract first. No model or ONNX library is loaded. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +const root = fileURLToPath(new URL("../../", import.meta.url)); +const binary = process.argv[2] || root + "server/target/debug/tokenling-contract"; +if (!existsSync(binary)) + throw new Error("Build tokenling-contract with cargo before running Rust parity."); +const expected = JSON.parse( + execFileSync("python3", [root + "test/parity_states.py", "1"], { maxBuffer: 1 << 26 }), +); +const actual = JSON.parse( + execFileSync(binary, { input: JSON.stringify(expected), maxBuffer: 1 << 26 }), +); +assert.deepEqual(actual.lists, expected.lists); +for (let i = 0; i < expected.cases.length; i++) { + for (const key of ["chat", "diary", "look", "clock", "recall"]) + assert.equal(actual.cases[i][key], expected.cases[i][key], `${i}: ${key}`); + assert.ok( + actual.cases[i].wake.every((p, j) => Math.abs(p - expected.cases[i].wake[j]) < 1e-12), + `${i}: wake chances`, + ); +} +console.log( + `${expected.cases.length} Rust/Python states, ${Object.keys(expected.lists).length} lists, 0 mismatches`, +); diff --git a/site/build.sh b/site/build.sh index 869383a..664bdec 100755 --- a/site/build.sh +++ b/site/build.sh @@ -17,6 +17,8 @@ # SOURCE URL for the page's source link (default: the release's "source", else #) # CF_ANALYTICS_TOKEN optional Cloudflare Web Analytics site token (32 hex # characters); overrides release analyticsToken. Empty disables it. +# REMOTE_ENDPOINT optional default remote-host URL (else release remoteEndpoint, +# else empty). Local remains the default execution mode. # MODEL_ID, SIZE override the release's model (dev-site.sh uses these) # OUT_DIR is replaced except for models/ and runtime/, which are left alone. # Copying, rendering and validation finish in a sibling staging directory before @@ -75,6 +77,7 @@ values = { # JSON values for the config block "NEWS": json.dumps(r.get("news", "")), "FEATURES": json.dumps(r.get("features", [])), "OLDER": json.dumps([o for o in r.get("older", []) if o["id"] != mid]), + "REMOTE_ENDPOINT": json.dumps(os.environ.get("REMOTE_ENDPOINT", r.get("remoteEndpoint", ""))).replace("<", "\\u003c"), } def render(path): s = re.sub(r"@([A-Z_]+)@", lambda m: values.get(m.group(1), m.group(0)), open(path).read()) diff --git a/site/index.html b/site/index.html index ee15631..9a46bee 100644 --- a/site/index.html +++ b/site/index.html @@ -16,7 +16,7 @@ tokenling - + @@ -116,12 +116,19 @@ .pet-setup { background: var(--card); border-radius: 22px; padding: 14px 16px 16px; } .pet-setup p { margin: 0 0 8px; } .pet-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } - input[type=text], input:not([type]) { flex: 1 1 150px; min-width: 0; font: 600 16px var(--font); color: var(--ink); background: var(--soft); + input[type=text], input[type=url], input:not([type]) { flex: 1 1 150px; min-width: 0; font: 600 16px var(--font); color: var(--ink); background: var(--soft); border: 2px solid var(--line); border-radius: 14px; padding: 9px 13px; } input:not([type]):focus { border-color: var(--accent); outline: none; } label { display: block; font-size: 14px; font-weight: 800; margin: 10px 0 6px; } .pet-check { display: flex; gap: 10px; align-items: flex-start; font-weight: 600; } .pet-check input { width: 18px; height: 18px; margin-top: 2px; accent-color: var(--accent); flex: none; } + .hosting-choices { border: 0; padding: 0; margin: 14px 0; min-width: 0; } + .hosting-choices legend { font-weight: 800; margin-bottom: 8px; } + .hosting-option { display: flex; gap: 10px; align-items: flex-start; padding: 10px; margin: 8px 0; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); cursor: pointer; } + .hosting-option input[type=radio] { width: 18px; height: 18px; margin: 3px 0 0; flex: none; accent-color: var(--accent); } + .hosting-option span { min-width: 0; } + .hosting-option small { display: block; font-weight: 500; line-height: 1.45; margin-top: 3px; } + .hosting-choices input[type=url] { display: block; width: 100%; box-sizing: border-box; } .primary { font: 900 15px var(--font); color: var(--accent-ink); background: var(--accent); border: 0; border-radius: 999px; padding: 10px 18px; cursor: pointer; box-shadow: 0 3px 0 color-mix(in srgb, var(--accent) 60%, #000); } .primary:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 color-mix(in srgb, var(--accent) 60%, #000); } @@ -243,7 +250,7 @@

tokenling

-

a tiny pet with a GPT-2 brain, living in your browser

+

a tiny pet with a GPT-2 brain, at home on your device or a server

@@ -256,8 +263,8 @@

The page only runs its body (getting hungry, sleepy and messy) and its little room. GPT-2 is small, so your pet is a bit silly. That's part of the charm.

-

Your pet stays here

-

The brain downloads once (@SIZE_MB@ MB, 4-bit) into this browser's storage and runs on your device with WebAssembly. Your pet, chats and diary stay in this browser and are never sent to a server.

+

Choose your pet's home

+

Choose local or remote when you get an egg, and switch either way later in settings. Locally, the brain downloads once (@SIZE_MB@ MB, 4-bit), runs with WebAssembly, and your pet, chats and diary stay in this browser. Remote mode uses a server you choose and trust: it stores your pet's save, chat and memories and keeps the pet thinking when this app is closed. This browser keeps a recovery copy, and exported saves can continue locally.

@ANALYTICS_NOTICE@

Pets grow up in real time: a kid after 12 hours, a teen after 2 days, an adult after 5. They never die.

@@ -275,7 +282,7 @@ diff --git a/site/sw.js b/site/sw.js index 4e57d73..b9a2d50 100644 --- a/site/sw.js +++ b/site/sw.js @@ -39,7 +39,7 @@ const pageKey = (url) => url.split(/[?#]/)[0]; self.addEventListener("fetch", (e) => { const req = e.request; - if (req.method !== "GET" || !req.url.startsWith(self.registration.scope)) return; + if (req.method !== "GET" || req.headers.has("Authorization") || new URL(req.url).pathname.includes("/api/") || !req.url.startsWith(self.registration.scope)) return; // transformers.js owns the model cache. Do not proxy its long download: // Firefox can end the service worker's response stream before it finishes. // Match the file, not a models/ path: deployments may override MODELS_URL. diff --git a/test/dev-site.sh b/test/dev-site.sh index fd74357..c77226c 100755 --- a/test/dev-site.sh +++ b/test/dev-site.sh @@ -19,6 +19,7 @@ # FEATURES JSON list of features the current brain has (default: []) # TRAINED text for the page's "Trained" line (default: local build) # SOURCE URL for the page's source link (default: #) +# REMOTE_ENDPOINT optional default Rust host URL (default: empty) # ONNX_BYTES_PER_SECOND throttle model downloads (default: 0, unlimited) # The speed benchmark (test/bench.html) is served at /$BASE/bench.html. # Nothing is published anywhere; this is for local testing. diff --git a/test/hosting-flow.js b/test/hosting-flow.js new file mode 100644 index 0000000..fe063d3 --- /dev/null +++ b/test/hosting-flow.js @@ -0,0 +1,171 @@ +// Disposable preview/profile only. Configure REMOTE_ENDPOINT in the preview and +// run: playwright-cli -s=host run-code --filename=test/hosting-flow.js +// Uses the real host and browser brains; never prints access keys. +async function hostingFlow(page) { + const check = (ok, message) => { + if (!ok) throw new Error(message); + }; + const ready = () => + page.waitForFunction(() => window.tokenling?.brain.state === "ready", null, { + timeout: 120000, + }); + const identity = () => + page.evaluate(() => { + const s = tokenling.state(); + return { owner: s.owner, name: s.pet.name, hatched: s.pet.hatched, traits: s.pet.traits }; + }); + const privateConnection = () => + page.evaluate(() => JSON.parse(localStorage.getItem("tokenling.remote.v1"))); + const readHost = async (c) => { + const response = await page.request.get(c.base + "/api/pets/" + c.id, { + headers: { Authorization: "Bearer " + c.key }, + }); + check(response.ok(), "Private host read failed"); + return response.json(); + }; + const results = []; + await page.waitForFunction(() => !!window.tokenling); + check(await page.locator("#setup-owner").isVisible(), "Use a fresh test profile"); + await page.setViewportSize({ width: 360, height: 900 }); + await page.fill("#owner-name", "Sam"); + await page.check("#setup-remote"); + check(!!(await page.inputValue("#setup-server")), "Configure REMOTE_ENDPOINT in the preview"); + let dropped = false; + const transfers = "**/api/pets/*/transfer"; + await page.route(transfers, async (route) => { + if (!dropped && route.request().postDataJSON().mode === "remote") { + dropped = true; + await route.fetch(); // Commit the activation, then lose its acknowledgement. + await route.abort("failed"); + } else await route.continue(); + }); + await page.click("#setup-owner button"); + await ready(); + await page.unroute(transfers); + await page.fill("#pet-name", "Mochi"); + await page.click("#hatch-btn"); + await page.waitForFunction( + () => tokenling.state().pet && tokenling.state().chat.some((l) => l.startsWith("Mochi:")), + null, + { timeout: 120000 }, + ); + check( + await page.evaluate( + () => + !tokenling.brain.worker && + !performance.getEntriesByType("resource").some((r) => r.name.includes(".onnx")), + ), + "Remote setup loaded a local model", + ); + check(dropped, "Lost acknowledgement was not exercised"); + results.push("remote creation and lost-activation reconciliation without model download"); + const original = await identity(); + const c = await privateConnection(); + const initial = await readHost(c); + const appUrl = page.url(); + await page.goto("about:blank"); + await page.waitForTimeout(20000); + const away = await readHost(c); + check(away.state.lastTick > initial.state.lastTick, "Host body stopped with the page closed"); + check(away.state.stats.tummy < initial.state.stats.tummy, "Host body did not decay"); + await page.goto(appUrl); + await ready(); + await page.click("#tab-care"); + await page.check("#hosting-local"); + await page.click("#hosting-switch"); + await ready(); + check( + await page.evaluate( + () => tokenling.state().hosting.mode === "local" && !!tokenling.brain.worker, + ), + "Local handoff did not load the browser brain", + ); + check( + JSON.stringify(await identity()) === JSON.stringify(original), + "Local handoff changed identity", + ); + check((await readHost(c)).authority === "local", "Old host executor was not parked"); + await page.check("#hosting-remote"); + await page.click("#hosting-switch"); + await ready(); + check( + await page.evaluate( + () => tokenling.state().hosting.mode === "remote" && !tokenling.brain.worker, + ), + "Return to remote kept a browser brain", + ); + check( + JSON.stringify(await identity()) === JSON.stringify(original), + "Remote handoff changed identity", + ); + results.push("closed-page body/thoughts and both transfer directions preserve identity"); + const outage = c.base + "/api/**"; + await page.route(outage, (route) => route.abort("failed")); + await page.waitForFunction( + () => document.querySelector("#hosting-recover")?.offsetParent !== null, + ); + // Auto-accept this recovery confirmation only in the disposable test page. + await page.evaluate(() => { + window.confirm = () => true; + }); + await page.click("#hosting-recover"); + await ready(); + check( + await page.evaluate(() => tokenling.state().hosting.mode === "local"), + "Offline recovery did not resume locally", + ); + const checkpoint = await page.evaluate(() => { + tokenling.state().memories.push("Sam likes frogs"); // Explicit fixture on the local recovery branch. + return tokenling.state().pet.hatched; + }); + const alternate = await page.evaluate((base) => { + const url = new URL(base); + if (!["localhost", "127.0.0.1"].includes(url.hostname)) return null; + url.hostname = url.hostname === "localhost" ? "127.0.0.1" : "localhost"; + return url.href; + }, c.base); + if (alternate) { + // Two loopback addresses stand in for different hosts while the first is + // unreachable. They create separate hosted pet records and capabilities. + await page.check("#hosting-remote"); + await page.fill("#hosting-server", alternate); + await page.click("#hosting-switch"); + await ready(); + check( + await page.evaluate(() => !!localStorage.getItem("tokenling.remote-stops.v1")), + "Moving to another host discarded the pending stop", + ); + } + await page.unroute(outage); + await page.evaluate(() => dispatchEvent(new Event("online"))); + await page.waitForFunction(() => !localStorage.getItem("tokenling.remote-stops.v1"), null, { + timeout: 30000, + }); + check((await readHost(c)).authority === "local", "Recovered host was not stopped on reconnect"); + check( + await page.evaluate( + (h) => + tokenling.state().pet.hatched === h && + tokenling.state().memories.includes("Sam likes frogs"), + checkpoint, + ), + "A stop response overwrote newer local progress", + ); + results.push("offline checkpoint recovery and eventual stop preserve the local branch"); + if (alternate) results.push("a new host preserves and delivers the unreachable old host's stop"); + for (const width of [1280, 768, 360]) { + await page.setViewportSize({ width, height: 900 }); + for (const panel of ["chat", "diary", "shelf", "care"]) { + await page.click("#tab-" + panel); + check( + await page.evaluate(() => document.documentElement.scrollWidth === innerWidth), + `Overflow in ${panel} at ${width}`, + ); + } + } + results.push("hosting controls and panels fit 1280/768/360 px"); + await page.evaluate((r) => { + window.hostingFlowResults = r; + }, results); + return results; +} diff --git a/test/storage.test.mjs b/test/storage.test.mjs index d5be08b..61e6d19 100644 --- a/test/storage.test.mjs +++ b/test/storage.test.mjs @@ -1,15 +1,19 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { parseSave, VERSION } from "../web/app/save-format.ts"; +import { fresh, parseSave, VERSION } from "../web/app/save-format.ts"; import { store } from "../web/app/store.ts"; import { KEY, load, save, cancelSave } from "../web/app/storage.ts"; const raw = readFileSync(new URL("fixtures/save-v1.json", import.meta.url), "utf8"); const original = JSON.parse(raw); -test("the previous vanilla app's browser save round-trips without a migration", () => { - assert.equal(VERSION, 1); - assert.deepEqual(parseSave(raw), original); +test("the previous browser save migrates to local mode without losing pet data", () => { + assert.equal(VERSION, 2); + const loaded = parseSave(raw); + assert.deepEqual(loaded, { ...original, v: 2, hosting: fresh().hosting }); + const { hosting, ...preserved } = loaded; + assert.deepEqual({ ...preserved, v: 1 }, original); + assert.equal(hosting.mode, "local"); }); test("missing optional top-level fields are supplied without losing older settings", () => { const older = structuredClone(original); @@ -55,7 +59,7 @@ test("bad saves, reset and loss of ownership block debounced writes", async () = store.resetting = false; save(); await new Promise((resolve) => setTimeout(resolve, 240)); - assert.deepEqual(JSON.parse(entries.get(KEY)), original); + assert.deepEqual(JSON.parse(entries.get(KEY)), { ...original, v: 2, hosting: fresh().hosting }); cancelSave(); delete globalThis.localStorage; }); diff --git a/web/app/brain.ts b/web/app/brain.ts index a887b53..4ddd585 100644 --- a/web/app/brain.ts +++ b/web/app/brain.ts @@ -5,6 +5,8 @@ import { debugTurn } from "./debug.ts"; import { keepOffline } from "./pwa.ts"; import { brain, emit, store, toast, ui, upgrade } from "./store.ts"; import { errorMessage, fmtMB } from "./utils.ts"; +import { isRemote, resumeRemote } from "./hosting.ts"; +import { save } from "./storage.ts"; let genId = 0; const pending = new Map< @@ -41,7 +43,13 @@ const brainInfo = (id: string | null) => BRAINS.find((b) => b.id === id); // config lists which brains have which features. export const brainHas = (f?: string) => - !f || (brain.id ? brainInfo(brain.id)?.features || [] : BRAINS[0].features).includes(f); + !f || + (isRemote() + ? brain.remoteFeatures || [] + : brain.id + ? brainInfo(brain.id)?.features || [] + : BRAINS[0].features + ).includes(f); export const olderBrain = () => !!brain.id && brain.id !== CFG.modelId; export async function cachedBrains() { @@ -94,6 +102,8 @@ async function fetchBrain(id: string, onBytes: (bytes: number) => void) { } } export async function startUpgrade() { + if (isRemote() || ui.hostingBusy) return; + const execution = store.executionRevision; closeUpgrade(); if (upgrade.state === "downloading") return; if (!brain.worker) { @@ -123,6 +133,11 @@ export async function startUpgrade() { } // Swap once the old brain is between turns. while (brain.busy) await new Promise((r) => setTimeout(r, 200)); + if (isRemote() || execution !== store.executionRevision) { + upgrade.state = "idle"; + emit(); + return; + } brain.worker?.terminate(); clearTimeout(watchdog); Object.assign(brain, { worker: null, state: "off", busy: false, progress: null, id: null }); @@ -168,7 +183,7 @@ function armWatchdog() { } export async function loadBrain(id = CFG.modelId) { - if (brain.worker || !store.tabOwned) return; + if (brain.worker || !store.tabOwned || store.badEgg || store.resetting || isRemote()) return; // Firefox may leave its persistence permission prompt unanswered. Storage // persistence is optional and must not hold up the download (or a retry). try { @@ -218,6 +233,11 @@ export async function loadBrain(id = CFG.modelId) { } else if (data.type === "ready") { clearTimeout(watchdog); brain.state = "ready"; + if (!isRemote()) { + store.state.hosting.brainId = id; + brain.remoteFeatures = undefined; + save(); + } if (upgrade.state === "swapping") { upgrade.state = "idle"; toast(`${store.state.pet?.name || "Your pet"} has its new brain!`, "sparkle"); @@ -300,4 +320,15 @@ export function stopBrain() { Object.assign(brain, { worker: null, state: "off", busy: false, progress: null, id: null }); emit(); } -export const getBrain = () => (olderBrain() ? startUpgrade() : loadBrain()); +export const getBrain = () => + ui.hostingBusy + ? undefined + : isRemote() + ? resumeRemote() + : olderBrain() + ? startUpgrade() + : loadBrain( + BRAINS.some((b) => b.id === store.state.hosting.brainId) + ? store.state.hosting.brainId! + : CFG.modelId, + ); diff --git a/web/app/canvas.ts b/web/app/canvas.ts index 521c86d..8a6bb7f 100644 --- a/web/app/canvas.ts +++ b/web/app/canvas.ts @@ -6,6 +6,8 @@ import { gameControl, stepGame, stopGameControls } from "./game.ts"; import { tick, weatherAt } from "./simulation.ts"; import { brain, store, view } from "./store.ts"; import { clamp } from "./utils.ts"; +import { isRemote } from "./hosting.ts"; +import { ui } from "./store.ts"; function frame(now: number) { if (!store.tabOwned) return; // another tab has the pet now @@ -31,7 +33,7 @@ function frame(now: number) { ctx, { lightsOff: store.state.lightsOff, - weather: weatherAt(Date.now()), + weather: isRemote() && ui.remoteWeather ? ui.remoteWeather : weatherAt(Date.now()), hour: d.getHours(), shelf: store.state.shelf, poop: store.state.poop, diff --git a/web/app/care.ts b/web/app/care.ts index 6968d6a..1a0ecee 100644 --- a/web/app/care.ts +++ b/web/app/care.ts @@ -10,6 +10,8 @@ import { logToday, weatherAt } from "./simulation.ts"; import { save } from "./storage.ts"; import { brain, emit, store, toast, view } from "./store.ts"; import { clamp, dateKey, pick } from "./utils.ts"; +import { isRemote, remoteCommand } from "./hosting.ts"; +import { ui } from "./store.ts"; // ---------- egg & hatching ---------- // The egg incubates while the brain downloads: no brain, no pet. @@ -124,7 +126,11 @@ export function answerOffer(yes: boolean) { // Feeding, baths, medicine and catch are offers the brain answers. Pats, // cleaning and the lights just happen, and the brain reacts to them. export function act(kind: Action, arg?: string) { - if (!store.tabOwned || !store.state.pet || view.game) return; + if (!store.tabOwned || !store.state.pet || view.game || ui.hostingBusy) return; + if (isRemote()) { + if (brain.state === "ready") void remoteCommand({ type: "care", kind, item: arg || null }); + return; + } const o = store.state.owner, p = store.state.pet!.name; if (brain.state !== "ready") { diff --git a/web/app/commands.ts b/web/app/commands.ts index 5bcb4b7..1e88f06 100644 --- a/web/app/commands.ts +++ b/web/app/commands.ts @@ -6,27 +6,50 @@ import { logToday } from "./simulation.ts"; import { BAD_KEY, cancelSave, KEY, parseSave, save } from "./storage.ts"; import { brain, emit, store, toast, upgrade } from "./store.ts"; import { cleanName, dateKey, download, errorMessage } from "./utils.ts"; +import { + isRemote, + moveToRemote, + moveToLocal, + remoteCommand, + recoverySave, + deleteRemote, +} from "./hosting.ts"; +import type { HostingMode } from "../types.ts"; +import { CFG } from "./config.ts"; +import { ui } from "./store.ts"; -export function getEgg(name: string) { - if (!store.tabOwned || store.badEgg) return; +export function getEgg( + name: string, + mode: HostingMode = "local", + endpoint = CFG.remoteEndpoint || "", +) { + if (!store.tabOwned || store.badEgg || ui.hostingBusy) return; const value = cleanName(name, 24); if (!value) return; store.state.owner = value; store.state.egg ||= { laid: Date.now() }; save(); emit(); - void loadBrain(); + if (mode === "remote") void moveToRemote(endpoint); + else void loadBrain(); } export function hatchPet(name: string) { const value = cleanName(name, 16); + if (isRemote()) { + if (value && brain.state === "ready" && !ui.hostingBusy) + void remoteCommand({ type: "hatch", name: value }); + return; + } if (value && store.tabOwned && !store.badEgg && brain.state === "ready") hatch(value); } -export function sendMessage(value: string) { +export async function sendMessage(value: string) { const text = value .replace(/[\n\[\]{}*]/g, " ") .trim() .slice(0, 200); - if (!text || !store.state.pet || brain.state !== "ready" || !store.tabOwned) return false; + if (!text || !store.state.pet || brain.state !== "ready" || !store.tabOwned || ui.hostingBusy) + return false; + if (isRemote()) return remoteCommand({ type: "message", text }); store.state.lastActive = Date.now(); disturb("talk"); pushLine(`${store.state.owner}: ${text}`); @@ -37,6 +60,11 @@ export function sendMessage(value: string) { } export function renameOwner(value: string) { const name = cleanName(value, 24); + if (ui.hostingBusy) return; + if (isRemote()) { + if (name) void remoteCommand({ type: "rename", name }); + return; + } if (name) { store.state.owner = name; save(); @@ -44,17 +72,29 @@ export function renameOwner(value: string) { } } export function setChatter(value: boolean) { + if (ui.hostingBusy) return; + if (isRemote()) { + void remoteCommand({ type: "chatter", value }); + return; + } store.state.settings.chatter = value; scheduleIdle(); save(); emit(); } export function forgetMemory(index: number) { + if (ui.hostingBusy) return; + if (isRemote()) { + const fact = store.state.memories[index]; + if (fact !== undefined) void remoteCommand({ type: "forgetMemory", index, fact }); + return; + } store.state.memories.splice(index, 1); save(); emit(); } export async function deleteBrain() { + if (ui.hostingBusy || isRemote()) return; if ( !confirm( `Delete the downloaded brain from this browser? ${store.state.pet?.name || "Your pet"} will doze until you download it again. The pet and diary stay.`, @@ -67,12 +107,14 @@ export async function deleteBrain() { emit(); } export function exportSave() { + if (ui.hostingBusy) return; download( - JSON.stringify(store.state, null, 1), + JSON.stringify(recoverySave(), null, 1), `tokenling-${store.state.pet?.name || "egg"}-${dateKey(Date.now())}.json`, ); } export async function importSave(file: File) { + if (!store.tabOwned || ui.hostingBusy || store.resetting) return; try { const next = parseSave(await file.text()); if ( @@ -81,11 +123,17 @@ export async function importSave(file: File) { ) ) return; + if (isRemote()) { + await moveToLocal(); + if (isRemote()) + throw new Error("Bring your current pet back locally before importing a save."); + } // In-flight replies belong to the previous pet. Reload also resets animation, // queued offers/turns and ensures Web Lock ownership before reading the import. store.resetting = true; cancelSave(); stopBrain(); + next.hosting.mode = "local"; try { localStorage.setItem(KEY, JSON.stringify(next)); } catch (error) { @@ -94,19 +142,30 @@ export async function importSave(file: File) { } location.reload(); } catch (error) { + store.resetting = false; toast(`Could not import: ${errorMessage(error)}`); } } -export function resetSave() { +export async function resetSave() { + if (!store.tabOwned || ui.hostingBusy || store.resetting) return; if ( !confirm( - `Say goodbye to ${store.state.pet?.name || "your egg"} and start over? This deletes the pet, your name, chat, memories, gifts and diary from this browser.`, + `Say goodbye to ${store.state.pet?.name || "your egg"} and start over? This deletes the pet, your name, chat, memories, gifts and diary from ${isRemote() ? "your server and this browser" : "this browser"}.`, ) ) return; store.resetting = true; cancelSave(); stopBrain(); + if (isRemote()) { + try { + await deleteRemote(); + } catch (error) { + store.resetting = false; + toast(`Could not delete the server's pet: ${errorMessage(error)}`); + return; + } + } try { localStorage.removeItem(KEY); } catch { diff --git a/web/app/conversation.ts b/web/app/conversation.ts index 6c9ac54..cebb4cd 100644 --- a/web/app/conversation.ts +++ b/web/app/conversation.ts @@ -10,6 +10,7 @@ import { save } from "./storage.ts"; import { brain, emit, liveBubble, store, toast, view } from "./store.ts"; import { cleanText, NOT_CUTE } from "./text.ts"; import { rand } from "./utils.ts"; +import { canRunLocally } from "./hosting.ts"; let lineCount = 0; // bumps on every new line; a turn whose context changed is stale export const isSilent = (body: string) => !/[a-z]/i.test(cleanText(body)); @@ -76,6 +77,7 @@ export function wantTurn(kind: TurnKind = "react") { } export function pump() { if ( + !canRunLocally() || !store.tabOwned || !turnWanted || brain.busy || @@ -96,6 +98,7 @@ function idleGap() { } export function maybeIdle() { if ( + !canRunLocally() || brain.state !== "ready" || brain.busy || turnWanted || @@ -111,7 +114,15 @@ export function maybeIdle() { let staleRuns = 0; export async function petTurn(kind: TurnKind = "react") { - if (!store.tabOwned || !store.state.pet || brain.state !== "ready" || brain.busy) return false; + if ( + !canRunLocally() || + !store.tabOwned || + !store.state.pet || + brain.state !== "ready" || + brain.busy + ) + return false; + const execution = store.executionRevision; brain.busy = true; emit(); view.thinking = kind === "react"; @@ -132,6 +143,7 @@ export async function petTurn(kind: TurnKind = "react") { text = ""; } bubble?.remove(); + if (execution !== store.executionRevision || !canRunLocally() || !store.tabOwned) return false; // Something happened while it was thinking (a message, an offer, a pat): // throw this line away and think again with the new context. if (store.state.asleep !== wasAsleep || (lineCount !== seen && staleRuns < 2)) { @@ -153,16 +165,19 @@ export async function petTurn(kind: TurnKind = "react") { bubble?.remove(); console.warn(e); } finally { - brain.busy = false; - view.thinking = false; - nextIdle = Math.max(nextIdle, Date.now() + idleGap()); - emit(); + if (execution === store.executionRevision) { + brain.busy = false; + view.thinking = false; + nextIdle = Math.max(nextIdle, Date.now() + idleGap()); + emit(); + } } return true; } // Effects of the pet's own tool calls. export function applyTags(text: string) { + if (!canRunLocally()) return; const sleeping = store.state.asleep; const tags = [...text.matchAll(Wd.TAG_RE)]; const answers = tags.filter((m) => Wd.ANSWER_TOOLS.has(m[1])); diff --git a/web/app/debug.ts b/web/app/debug.ts index 87d2a1e..4b333a4 100644 --- a/web/app/debug.ts +++ b/web/app/debug.ts @@ -7,6 +7,7 @@ import { writeDiary } from "./diary.ts"; import { simulate, stageFor } from "./simulation.ts"; import { save } from "./storage.ts"; import { brain, emit, store, toast, ui, upgrade, view } from "./store.ts"; +import { canRunLocally } from "./hosting.ts"; export function debugTurn(opts: Generation, result: Generated) { if (!DEBUG) return; ui.debugLog.push({ ...result, at: new Date(), mode: opts.mode }); @@ -14,6 +15,7 @@ export function debugTurn(opts: Generation, result: Generated) { emit(); } export function setAge(ms: number) { + if (!canRunLocally()) return; const pet = store.state.pet; if (!pet) return; pet.hatched = Date.now() - ms; diff --git a/web/app/diary.ts b/web/app/diary.ts index c5e948a..9974f65 100644 --- a/web/app/diary.ts +++ b/web/app/diary.ts @@ -8,6 +8,7 @@ import { dayNum, weatherAt, worldState } from "./simulation.ts"; import { save } from "./storage.ts"; import { brain, emit, store, toast, ui, view } from "./store.ts"; import { NOT_CUTE } from "./text.ts"; +import { canRunLocally } from "./hosting.ts"; // ---------- diary ---------- export function onSleep() { @@ -32,6 +33,7 @@ const isNight = () => Wd.partOfDay(new Date().getHours()) === "night"; // (onSleep), and missed days (store.state.history) one at a time, oldest first, when the // brain is free and the owner has left it alone for 30 s. export function maybeDiary() { + if (!canRunLocally()) return; const k = Object.keys(store.state.history).sort()[0]; if ( !k || @@ -48,7 +50,8 @@ export function maybeDiary() { } export async function writeDiary(dateKeyOverride?: string, rewrite = false) { - if (brain.state !== "ready" || brain.busy || !store.state.pet) return false; + if (!canRunLocally() || brain.state !== "ready" || brain.busy || !store.state.pet) return false; + const execution = store.executionRevision; const k = dateKeyOverride || store.state.today.date; const src = k === store.state.today.date @@ -77,6 +80,7 @@ export async function writeDiary(dateKeyOverride?: string, rewrite = false) { }, ); let text = ("dear diary," + r.text).trim(); + if (execution !== store.executionRevision || !canRunLocally() || !store.tabOwned) return false; if (NOT_CUTE.test(text)) text = text .split(/(?<=[.!?])\s+/) @@ -110,7 +114,7 @@ export async function writeDiary(dateKeyOverride?: string, rewrite = false) { } catch (e) { console.warn(e); } finally { - brain.busy = false; + if (execution === store.executionRevision) brain.busy = false; ui.diaryWriting = false; emit(); pump(); diff --git a/web/app/game.ts b/web/app/game.ts index 6a503da..298a948 100644 --- a/web/app/game.ts +++ b/web/app/game.ts @@ -6,6 +6,8 @@ import { logToday } from "./simulation.ts"; import { save } from "./storage.ts"; import { emit, store, view } from "./store.ts"; import { clamp } from "./utils.ts"; +import { isRemote, remoteCommand } from "./hosting.ts"; +import { ui } from "./store.ts"; // ---------- catch minigame (after the pet said yes) ---------- export function startGame() { @@ -80,6 +82,11 @@ export function endGame() { view.game = null; view.target = view.x; emit(); + if (isRemote()) { + if (ui.remoteGameId) + void remoteCommand({ type: "gameComplete", gameId: ui.remoteGameId, score: n }); + return; + } store.state.stats.fun = clamp(store.state.stats.fun + 8 + n * 3); store.state.stats.energy = clamp(store.state.stats.energy - 8); store.state.counts.games++; diff --git a/web/app/hosting.ts b/web/app/hosting.ts new file mode 100644 index 0000000..cb0fcd9 --- /dev/null +++ b/web/app/hosting.ts @@ -0,0 +1,568 @@ +import type { HostingMode, PetState, RemoteCommand, RemoteSnapshot } from "../types.ts"; +import * as Wd from "../world.ts"; +import { animate, bubbles, float, walk, want } from "./animation.ts"; +import { BRAINS, loadBrain, stopBrain } from "./brain.ts"; +import { CFG } from "./config.ts"; +import { startGame, stopGameControls } from "./game.ts"; +import { catchUp, resetSimulationClock } from "./simulation.ts"; +import { parseSave, save, saveNow } from "./storage.ts"; +import { brain, emit, store, toast, ui, view } from "./store.ts"; +import { errorMessage } from "./utils.ts"; + +const CONNECTION_KEY = "tokenling.remote.v1"; +const STOPS_KEY = "tokenling.remote-stops.v1"; +interface PendingStop { + base: string; + id: string; + key: string; + deviceId: string; + epoch: number; + operationId: string; + activation?: boolean; +} +interface Connection { + base: string; + id: string; + key: string; + epoch: number; + deviceId: string; + created: number; + activation?: { epoch: number; operationId: string }; + release?: { epoch: number; operationId: string }; +} +let timer = 0; +let synchronizing = false; +let stopping = false; +let stopTimer = 0; +let lastEffect = 0; +let commanded = false; +let seen: { id: string; epoch: number; revision: number } | null = null; +let initializedEffects = false; +export const isRemote = () => store.state.hosting.mode === "remote"; +export const canRunLocally = () => !isRemote() && !ui.hostingBusy; + +function connection(): Connection | null { + try { + const raw = localStorage.getItem(CONNECTION_KEY); + if (!raw) return null; + const value: Connection = JSON.parse(raw); + return value.created === store.state.created ? value : null; + } catch { + return null; + } +} +function keepConnection(value: Connection) { + localStorage.setItem(CONNECTION_KEY, JSON.stringify(value)); + ui.remoteEndpoint = value.base; +} +function pendingStops(): PendingStop[] { + const raw = localStorage.getItem(STOPS_KEY); + return raw ? JSON.parse(raw) : []; +} +function keepStops(stops: PendingStop[]) { + if (stops.length) localStorage.setItem(STOPS_KEY, JSON.stringify(stops)); + else localStorage.removeItem(STOPS_KEY); +} +function queueStop(c: Connection) { + if (!c.release) return; + const stops = pendingStops(); + if (!stops.some((s) => s.base === c.base && s.id === c.id)) { + stops.push({ + base: c.base, + id: c.id, + key: c.key, + deviceId: c.deviceId, + ...c.release, + activation: !!c.activation, + }); + keepStops(stops); + } +} +export function stopPendingStops() { + clearTimeout(stopTimer); + stopTimer = 0; +} +export function normalizeEndpoint(value: string) { + const url = new URL(value); + if ( + url.username || + url.password || + url.search || + url.hash || + !["http:", "https:"].includes(url.protocol) + ) + throw new Error("Enter the server's web address, without a password, query or fragment."); + if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + throw new Error("Use an HTTPS server address."); + return url.href.replace(/\/+$/, ""); +} +async function request( + base: string, + path: string, + key?: string, + body?: unknown, + method?: string, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(`${base}/api/${path}`, { + method: method || (body === undefined ? "GET" : "POST"), + headers: { + ...(key ? { Authorization: `Bearer ${key}` } : {}), + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + cache: "no-store", + credentials: "omit", + signal: controller.signal, + }); + const result = await response.json(); + if (!response.ok) + throw new Error(result.error || `The server returned HTTP ${response.status}.`); + return result; + } finally { + clearTimeout(timeout); + } +} +function applyEffects(snapshot: RemoteSnapshot) { + for (const e of snapshot.effects) { + if (e.seq <= lastEffect) continue; + lastEffect = e.seq; + if (e.kind === "emote" && Wd.EMOTES.includes(e.arg)) + animate(e.arg, e.arg === "spin" ? 1300 : 2000); + else if (e.kind === "walk" && Wd.WALKS.includes(e.arg) && !store.state.asleep) walk(e.arg); + else if (e.kind === "want" && Wd.WANTS.includes(e.arg)) want(e.arg); + else if (e.kind === "dream" && Wd.DREAMS.includes(e.arg) && store.state.asleep) { + view.dream = e.arg; + view.dreamUntil = performance.now() + 25000; + } else if (e.kind === "eat" && Wd.FOODS.includes(e.arg)) { + animate("eat", 1700, true); + view.effects.push({ + kind: "food", + item: e.arg, + t0: performance.now(), + until: performance.now() + 1700, + }); + } else if (e.kind === "bath") bubbles(14); + else if (e.kind === "pat") animate(store.state.asleep ? "stretch" : "pat", 600, true); + else if (e.kind === "sparkle") float("sparkle", 3); + else if (e.kind === "gift" && Wd.GIFTS.includes(e.arg)) float(e.arg, 1); + else if (e.kind === "sleep") { + view.target = view.x; + view.want = null; + view.dream = null; + view.queue = []; + animate("sleepy", 1500, true); + } + } +} +function adopt(snapshot: RemoteSnapshot, c: Connection) { + if (!store.tabOwned || store.badEgg || store.resetting) return; + const current = connection(); + if (current?.id === c.id && snapshot.epoch < current.epoch) return; + if ( + seen?.id === c.id && + (snapshot.epoch < seen.epoch || + (snapshot.epoch === seen.epoch && snapshot.revision < seen.revision)) + ) + return; + const next = parseSave(JSON.stringify(snapshot.state)); + if (!BRAINS.some((b) => b.id === snapshot.modelId)) + throw new Error( + "This page cannot run the server's selected brain. Keep your current recovery save and update the page.", + ); + c.epoch = snapshot.epoch; + keepConnection(c); + const devicePreferences = store.state.settings; + next.settings = { ...devicePreferences, chatter: next.settings.chatter }; + next.hosting.brainId = snapshot.modelId; + // Only the destination device may resume automatically after a lost handoff acknowledgement. + const handedHere = snapshot.authority === "local" && snapshot.localDevice === c.deviceId; + next.hosting.mode = handedHere ? "local" : "remote"; + store.state = next; + seen = { id: c.id, epoch: snapshot.epoch, revision: snapshot.revision }; + ui.remoteWeather = snapshot.weather; + ui.remoteLastSync = Date.now(); + ui.remoteError = + snapshot.authority === "local" && !handedHere + ? "Your pet has moved to another device. This is its last server checkpoint." + : snapshot.lastError && !snapshot.thinking + ? "The server's brain could not finish its turn. It will retry shortly." + : ""; + if (!snapshot.game && ui.remoteGameId && view.game) { + view.game = null; + view.target = view.x; + } + ui.remoteGameId = snapshot.game?.id || null; + Object.assign(brain, { + id: snapshot.modelId, + state: ui.remoteError ? "off" : "ready", + busy: snapshot.thinking, + remoteFeatures: snapshot.features, + }); + view.thinking = snapshot.thinking; + if (!initializedEffects) { + lastEffect = snapshot.effects.at(-1)?.seq || 0; + initializedEffects = true; + } + applyEffects(snapshot); + if (snapshot.game && !view.game && !ui.hostingBusy) startGame(); + saveNow(); + emit(); + if (handedHere) { + stopRemote(); + stopBrain(); + resetSimulationClock(); + catchUp(); + void loadBrain(snapshot.modelId); + } +} +export function stopRemote() { + clearTimeout(timer); + timer = 0; +} +async function syncRemote() { + if (!isRemote() || !store.tabOwned || ui.hostingBusy) return; + if (synchronizing) { + timer = setTimeout(syncRemote, 250); + return; + } + const c = connection(); + if (!c) { + ui.remoteError = + "This browser no longer has the server access key. You can recover locally from its saved checkpoint."; + brain.state = "error"; + emit(); + return; + } + synchronizing = true; + const execution = store.executionRevision; + try { + let snapshot = await request(c.base, `pets/${c.id}`, c.key); + if (execution !== store.executionRevision || !isRemote() || ui.hostingBusy) return; + if (c.activation && snapshot.authority === "local" && snapshot.epoch === c.activation.epoch) { + snapshot = await request(c.base, `pets/${c.id}/transfer`, c.key, { + ...c.activation, + mode: "remote", + deviceId: c.deviceId, + state: store.state, + }); + } + if (execution !== store.executionRevision || !isRemote() || ui.hostingBusy) return; + delete c.activation; + adopt(snapshot, c); + } catch (error) { + if (execution !== store.executionRevision || !isRemote()) return; + ui.remoteError = `Could not reach your pet's server: ${errorMessage(error)}`; + Object.assign(brain, { state: "error", error: ui.remoteError, busy: false }); + view.thinking = false; + emit(); + } finally { + synchronizing = false; + if (execution === store.executionRevision && isRemote() && store.tabOwned) + timer = setTimeout(syncRemote, document.hidden ? 15000 : 1000); + } +} +export async function resumeRemote() { + stopRemote(); + ui.remoteEndpoint = connection()?.base || CFG.remoteEndpoint || ""; + await syncRemote(); +} +export async function remoteCommand(command: RemoteCommand) { + if (!isRemote() || !store.tabOwned || ui.hostingBusy || commanded) return false; + const c = connection(); + if (!c) { + toast("The server access key is missing. Bring your pet back to local mode to recover it."); + return false; + } + commanded = true; + const execution = store.executionRevision; + try { + const snapshot = await request(c.base, `pets/${c.id}/commands`, c.key, { + epoch: c.epoch, + operationId: crypto.randomUUID(), + command, + }); + if (isRemote() && execution === store.executionRevision) adopt(snapshot, c); + return true; + } catch (error) { + toast(errorMessage(error)); + return false; + } finally { + commanded = false; + } +} +function beginMove() { + if (!store.tabOwned || store.badEgg || store.resetting || ui.hostingBusy) + throw new Error("This tab cannot move the pet right now."); + if (view.game) throw new Error("Finish the current game before moving your pet."); + ui.hostingBusy = true; + store.executionRevision++; + stopRemote(); + stopGameControls(); + stopBrain(); + view.thinking = false; + emit(); +} +export async function moveToRemote(address: string) { + if (ui.hostingBusy || isRemote()) return; + const previous = structuredClone(store.state); + const hadBrain = !!brain.worker; + let activationSent = false; + try { + const base = normalizeEndpoint(address); + beginMove(); + const modelId = previous.hosting.brainId || CFG.modelId; + const health = await request<{ protocol: number; modelId: string; cpu: boolean }>( + base, + "health", + ); + if (health.protocol !== 1 || !health.cpu || health.modelId !== modelId) + throw new Error("That server cannot run your pet's selected brain. Your local save is safe."); + let c = connection(); + if (c?.release) queueStop(c); + let prepared: RemoteSnapshot | null = null; + if (c && c.base === base) { + if (c.release) { + try { + const released = await request(c.base, `pets/${c.id}/transfer`, c.key, { + ...c.release, + mode: "local", + deviceId: c.deviceId, + state: null, + }); + c.epoch = released.epoch; + delete c.release; + keepConnection(c); + keepStops(pendingStops().filter((s) => s.base !== c!.base || s.id !== c!.id)); + } catch { + c = null; + } + } + if (c) { + try { + prepared = await request(base, `pets/${c.id}`, c.key); + if (prepared.authority !== "local") c = null; + } catch { + c = null; + } + } + } else c = null; + if (!c) { + const key = [...crypto.getRandomValues(new Uint8Array(32))] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + c = { + base, + id: crypto.randomUUID(), + key, + deviceId: crypto.randomUUID(), + epoch: 0, + created: previous.created, + }; + keepConnection(c); + prepared = await request(base, "pets", c.key, { + id: c.id, + state: previous, + modelId, + }); + } + c.epoch = prepared!.epoch; + c.activation = { epoch: c.epoch, operationId: crypto.randomUUID() }; + keepConnection(c); + store.state.hosting = { ...store.state.hosting, mode: "remote", brainId: modelId }; + store.state.hosting.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + saveNow(); // durable intent comes before activation, so a reload cannot run two copies + activationSent = true; + const snapshot = await request(base, `pets/${c.id}/transfer`, c.key, { + ...c.activation, + mode: "remote", + deviceId: c.deviceId, + state: store.state, + }); + delete c.activation; + lastEffect = snapshot.effects.at(-1)?.seq || 0; + initializedEffects = true; + adopt(snapshot, c); + toast(`${store.state.pet?.name || "Your egg"} is now living on your server.`); + } catch (error) { + // A timed-out activation may have succeeded. Its persisted remote intent must be reconciled first. + if (!activationSent) { + store.state = previous; + if (!previous.pet && !hadBrain) store.state.egg = null; + resetSimulationClock(); + save(); + if (previous.pet || hadBrain) void loadBrain(previous.hosting.brainId || CFG.modelId); + } + ui.remoteError = errorMessage(error); + toast(ui.remoteError); + } finally { + ui.hostingBusy = false; + emit(); + if (isRemote()) void resumeRemote(); + else catchUp(); + void releaseRecoveredCopy(); + } +} +export async function moveToLocal(recover = false) { + if (!isRemote() || ui.hostingBusy) return; + const c = connection(); + const preferences = store.state.settings; + try { + beginMove(); + if (!recover) { + if (!c) throw new Error("The access key is missing. Use the local recovery copy instead."); + const status = await request(c.base, `pets/${c.id}`, c.key); + let snapshot = status; + if (status.authority === "remote") { + c.epoch = status.epoch; + if (c.release?.epoch !== c.epoch) + c.release = { epoch: c.epoch, operationId: crypto.randomUUID() }; + keepConnection(c); + snapshot = await request(c.base, `pets/${c.id}/transfer`, c.key, { + ...c.release, + mode: "local", + deviceId: c.deviceId, + state: null, + }); + } else if (status.localDevice && status.localDevice !== c.deviceId) { + throw new Error( + "Your pet has moved to another device. You can make a local recovery copy from this browser's checkpoint.", + ); + } + if (status.authority === "local") { + // Fence a prepared record too: an older activation may still be in flight. + c.epoch = status.epoch; + if (c.release?.epoch !== c.epoch) + c.release = { epoch: c.epoch, operationId: crypto.randomUUID() }; + keepConnection(c); + snapshot = await request(c.base, `pets/${c.id}/transfer`, c.key, { + ...c.release, + mode: "local", + deviceId: c.deviceId, + state: null, + }); + } + store.state = parseSave(JSON.stringify(snapshot.state)); + c.epoch = snapshot.epoch; + delete c.activation; + delete c.release; + keepConnection(c); + keepStops(pendingStops().filter((s) => s.base !== c.base || s.id !== c.id)); + } else if (c) { + c.release ||= { epoch: c.epoch, operationId: crypto.randomUUID() }; + keepConnection(c); + queueStop(c); + } + store.state.hosting.mode = "local"; + store.state.settings = { ...preferences, chatter: store.state.settings.chatter }; + brain.remoteFeatures = undefined; + ui.remoteGameId = null; + ui.remoteError = ""; + view.game = null; + saveNow(); + resetSimulationClock(); + void loadBrain(store.state.hosting.brainId || CFG.modelId); + toast( + recover + ? "Continuing locally from this browser's last checkpoint. The server's copy will be paused when it reconnects." + : "Your pet is back on this device, with its memories and diary.", + ); + if (recover) void releaseRecoveredCopy(); + } catch (error) { + ui.remoteError = errorMessage(error); + toast(`Could not move your pet: ${ui.remoteError}`); + } finally { + ui.hostingBusy = false; + emit(); + if (isRemote()) void resumeRemote(); + else catchUp(); + } +} +export async function releaseRecoveredCopy() { + if (!store.tabOwned || store.resetting || stopping) return; + stopPendingStops(); + stopping = true; + try { + const c = connection(); + if (!isRemote() && c?.release) queueStop(c); + for (const stop of pendingStops()) { + if (!store.tabOwned || store.resetting) break; + try { + const status = await request(stop.base, `pets/${stop.id}`, stop.key); + if (!store.tabOwned || store.resetting) break; + // A lost activation acknowledgement is the only permitted epoch adjustment. + if (stop.activation && status.epoch === stop.epoch + 1 && status.authority === "remote") { + stop.epoch = status.epoch; + stop.operationId = crypto.randomUUID(); + stop.activation = false; + keepStops( + pendingStops().map((s) => (s.base === stop.base && s.id === stop.id ? stop : s)), + ); + } + let epoch = status.epoch; + if (!(status.authority === "local" && status.epoch > stop.epoch)) { + const snapshot = await request( + stop.base, + `pets/${stop.id}/transfer`, + stop.key, + { + epoch: stop.epoch, + operationId: stop.operationId, + mode: "local", + deviceId: stop.deviceId, + state: null, + }, + ); + epoch = snapshot.epoch; + } + if (!store.tabOwned || store.resetting) break; + keepStops(pendingStops().filter((s) => s.base !== stop.base || s.id !== stop.id)); + const current = connection(); + if ( + current?.base === stop.base && + current.id === stop.id && + current.release && + !isRemote() + ) { + current.epoch = epoch; + delete current.release; + delete current.activation; + keepConnection(current); + } + // A stop receipt never replaces state on a newer local or remote branch. + } catch { + /* retain the key and receipt even when this pet moves to another host */ + } + } + } catch (error) { + ui.remoteError = `Could not read the pending server stop: ${errorMessage(error)}`; + emit(); + } finally { + stopping = false; + try { + if (store.tabOwned && !store.resetting && pendingStops().length) + stopTimer = setTimeout(releaseRecoveredCopy, 30000); + } catch { + /* retain an unreadable stop receipt instead of overwriting its key */ + } + } +} +export async function deleteRemote() { + const c = connection(); + if (isRemote() && !c) + throw new Error("The access key is missing. Recover locally before starting over."); + if (isRemote() && c) await request(c.base, `pets/${c.id}`, c.key, undefined, "DELETE"); + stopRemote(); + localStorage.removeItem(CONNECTION_KEY); +} +export function recoverySave(): PetState { + const copy = structuredClone(store.state); + copy.hosting.mode = "local"; + return copy; +} +export async function changeHosting(mode: HostingMode, address: string) { + if (mode === "remote") await moveToRemote(address); + else await moveToLocal(); +} diff --git a/web/app/lifecycle.ts b/web/app/lifecycle.ts index d3e52ae..76515e5 100644 --- a/web/app/lifecycle.ts +++ b/web/app/lifecycle.ts @@ -1,10 +1,17 @@ -import { cachedBrains, loadBrain, stopBrain, updateStorage } from "./brain.ts"; +import { BRAINS, cachedBrains, loadBrain, stopBrain, updateStorage } from "./brain.ts"; import { CFG } from "./config.ts"; import { stopGameControls } from "./game.ts"; import { planAlerts } from "./pwa.ts"; import { catchUp } from "./simulation.ts"; import { cancelSave, fresh, KEY, load } from "./storage.ts"; import { brain, emit, store } from "./store.ts"; +import { + isRemote, + releaseRecoveredCopy, + resumeRemote, + stopRemote, + stopPendingStops, +} from "./hosting.ts"; let releaseTab: (() => void) | null = null; @@ -39,6 +46,8 @@ export function lostTab() { store.tabOwned = false; cancelSave(); stopBrain(); + stopRemote(); + stopPendingStops(); stopGameControls(); emit(); } @@ -76,18 +85,32 @@ export async function boot() { if (document.hidden) stopGameControls(); if (!store.tabOwned) return; if (!document.hidden) { - catchUp(); + if (isRemote()) void resumeRemote(); + else catchUp(); emit(); } void planAlerts(); }); addEventListener("blur", stopGameControls); + addEventListener("online", () => { + if (isRemote()) void resumeRemote(); + void releaseRecoveredCopy(); + }); void planAlerts(); void updateStorage(); } export async function resumeBrain() { if (!store.tabOwned || store.badEgg) return; + void releaseRecoveredCopy(); + if (isRemote()) { + await resumeRemote(); + return; + } const have = await cachedBrains(); - if (store.state.egg && store.state.owner) void loadBrain(CFG.modelId); - else if (store.state.pet && have.length && !brain.worker) void loadBrain(have[0]); + const selected = BRAINS.some((b) => b.id === store.state.hosting.brainId) + ? store.state.hosting.brainId + : null; + if (store.state.egg && store.state.owner) void loadBrain(selected || CFG.modelId); + else if (store.state.pet && have.length && !brain.worker) + void loadBrain(selected && have.includes(selected) ? selected : have[0]); } diff --git a/web/app/save-format.ts b/web/app/save-format.ts index 0dd8043..f1c90f9 100644 --- a/web/app/save-format.ts +++ b/web/app/save-format.ts @@ -3,7 +3,7 @@ import { dateKey, HOUR } from "./utils.ts"; export function fresh(): PetState { return { - v: 1, + v: 2, created: Date.now(), owner: "", pet: null, @@ -27,14 +27,21 @@ export function fresh(): PetState { nextPoop: Date.now() + 3 * HOUR, settings: { chatter: true }, counts: { fed: 0, pats: 0, games: 0, baths: 0 }, + hosting: { + mode: "local", + brainId: null, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + }, }; } // Save versions. MIGRATIONS[n] turns a version n+1 save into version n+2; // bump VERSION and add one whenever a stored shape changes. A save that cannot // be read (corrupt, from a newer version, or a failed migration) is never // overwritten: it becomes a "bad egg" that the owner can download or replace. -export const VERSION = 1; -export const MIGRATIONS: ((s: PetState) => PetState)[] = []; +export const VERSION = 2; +export const MIGRATIONS: ((s: PetState) => PetState)[] = [ + (s) => ({ ...s, v: 2, hosting: fresh().hosting }), +]; export function parseSave(raw: string): PetState { let s: PetState = JSON.parse(raw); if (!s || typeof s !== "object" || !Number.isInteger(s.v) || s.v < 1) @@ -56,5 +63,13 @@ export function parseSave(raw: string): PetState { } if (!Array.isArray(s.chat) || !Array.isArray(s.diary) || !s.stats || typeof s.stats !== "object") throw new Error("parts of it are missing"); + if ( + !s.hosting || + !["local", "remote"].includes(s.hosting.mode) || + typeof s.hosting.timeZone !== "string" || + !(s.hosting.brainId === null || typeof s.hosting.brainId === "string") + ) + throw new Error("its execution setting is missing or damaged"); + new Intl.DateTimeFormat("en", { timeZone: s.hosting.timeZone }); return { ...fresh(), ...s }; } diff --git a/web/app/simulation.ts b/web/app/simulation.ts index 90e029b..a8f5d97 100644 --- a/web/app/simulation.ts +++ b/web/app/simulation.ts @@ -8,6 +8,7 @@ import { maybeDiary, onSleep } from "./diary.ts"; import { save } from "./storage.ts"; import { emit, store, toast, view } from "./store.ts"; import { dateKey } from "./utils.ts"; +import { canRunLocally } from "./hosting.ts"; function hashStr(s: string) { let h = 2166136261; @@ -115,6 +116,7 @@ export function simulate(from: number, to: number, quiet: boolean) { } export function catchUp() { + if (!canRunLocally()) return; const now = Date.now(); const away = now - store.state.lastTick; if (store.state.pet && away > 2 * MIN) { @@ -157,7 +159,11 @@ export function growUp() { } let lastSecond = Date.now(); +export function resetSimulationClock() { + lastSecond = Date.now(); +} export function tick() { + if (!canRunLocally()) return; const now = Date.now(); if (now - lastSecond < 1000) return; const evs = simulate(lastSecond, now, false); diff --git a/web/app/storage.ts b/web/app/storage.ts index f14d871..01fc979 100644 --- a/web/app/storage.ts +++ b/web/app/storage.ts @@ -44,3 +44,10 @@ export const BAD_KEY = "tokenling.bad"; export function cancelSave() { clearTimeout(saveTimer); } +// Ownership changes need a confirmed write, not a best-effort debounce. +export function saveNow() { + if (store.resetting || store.badEgg || !store.tabOwned) + throw new Error("This tab cannot save the pet right now."); + cancelSave(); + localStorage.setItem(KEY, JSON.stringify(store.state)); +} diff --git a/web/app/store.ts b/web/app/store.ts index a659be1..4f16b74 100644 --- a/web/app/store.ts +++ b/web/app/store.ts @@ -8,11 +8,13 @@ export const store: { badEgg: { raw: string; why: string; newer: boolean } | null; tabOwned: boolean; resetting: boolean; + executionRevision: number; } = { state: fresh(), badEgg: null, tabOwned: false, resetting: false, + executionRevision: 0, }; export const brain: BrainState = { worker: null, @@ -54,6 +56,12 @@ export const ui = { alertsNote: "", toasts: [] as { id: number; text: string; icon?: string }[], debugLog: [] as (Generated & { at: Date; mode: Generation["mode"] })[], + hostingBusy: false, + remoteError: "", + remoteEndpoint: "", + remoteLastSync: 0, + remoteGameId: null as string | null, + remoteWeather: "", }; let revision = 0; const listeners = new Set<() => void>(); diff --git a/web/components/Brain.tsx b/web/components/Brain.tsx index 4a80695..1bc18c7 100644 --- a/web/components/Brain.tsx +++ b/web/components/Brain.tsx @@ -11,7 +11,31 @@ import { import { CFG, FEATURE_TEXT } from "../app/config.ts"; import { brain, store, ui, upgrade } from "../app/store.ts"; import { fmtMB } from "../app/utils.ts"; +import { isRemote } from "../app/hosting.ts"; export function BrainNotice() { + if (ui.hostingBusy || isRemote()) + return ( + + ); const name = store.state.pet?.name; const up = upgrade.state, dozing = brain.state === "off" || brain.state === "error"; @@ -87,6 +111,13 @@ export function UpgradeDialog() { ); } export function brainStatus() { + if (isRemote()) + return ( + ui.remoteError || + (brain.state === "ready" + ? `${brain.id || CFG.modelId}, connected to your server. Your pet keeps thinking while this app is closed.` + : "Connecting to the server's brain…") + ); const id = brain.id || CFG.modelId, up = upgrade.state; return { diff --git a/web/components/Chat.tsx b/web/components/Chat.tsx index 980d535..f578a85 100644 --- a/web/components/Chat.tsx +++ b/web/components/Chat.tsx @@ -5,6 +5,7 @@ import { getTip } from "../app/pwa.ts"; import { brain, store, ui } from "../app/store.ts"; import { ACT_WORD, chipsFor, cleanText } from "../app/text.ts"; import { Icon } from "./shared.tsx"; +import { isRemote } from "../app/hosting.ts"; export function Chat({ active }: { active: boolean }) { const s = store.state, ready = brain.state === "ready"; @@ -45,13 +46,23 @@ export function Chat({ active }: { active: boolean }) {

)} + {!ui.live && isRemote() && brain.busy && s.pet && ( +
  • + {s.pet.name} +

    + + + +

    +
  • + )}
    { + onSubmit={async (e) => { e.preventDefault(); - if (sendMessage(message)) setMessage(""); + if (await sendMessage(message)) setMessage(""); }} >