From eec34b14ed53575424128438331361edbca1bb4f Mon Sep 17 00:00:00 2001 From: Lumi Date: Sat, 10 Oct 2026 01:13:52 -0700 Subject: [PATCH] Run the browser and host on one shared Rust rules crate Move every rule both runtimes applied into core/ (tokenling-core): the prompt contract, stop and context-fit rules, the lookup line loop and text filters, the body, care and offers, tag effects, the day log and diary, turn scheduling and save reading. The host links it natively; core/wasm compiles it to WebAssembly for the page and its brain worker. The browser's local mode now runs the same engine as the host and plays the same effects, replacing web/world.ts, web/app/body.ts and the TypeScript copies of care, tags, turns, diary and save rules, and the host's types.rs, world.rs and its own simulation and turn code. Hosted rows keep their stored layout. Sampling settings travel in the shared request. The module is built by site/build.sh, hashed with the app and kept offline. Checks: npm test (300 parity states through WebAssembly, 13 engine/save tests), 6 core and 17 host Rust tests, native/Python parity plus a 31-step native/WebAssembly engine replay in two time zones, and both browser flows with the real v6 brain and native host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BmRXrH3X8VnAPbuCgo6PgW --- .gitignore | 5 +- AGENTS.md | 87 +- README.md | 33 +- core/Cargo.lock | 291 ++++++ core/Cargo.toml | 21 + core/README.md | 75 ++ core/src/body.rs | 202 +++++ core/src/calendar.rs | 62 ++ core/src/engine.rs | 846 ++++++++++++++++++ core/src/lib.rs | 12 + core/src/save.rs | 150 ++++ core/src/turn.rs | 174 ++++ {server => core}/src/types.rs | 104 ++- core/src/world.rs | 442 +++++++++ core/tests/rules.rs | 222 +++++ .../wasm-core-proof => core/wasm}/Cargo.lock | 61 +- .../wasm-core-proof => core/wasm}/Cargo.toml | 13 +- core/wasm/build.sh | 38 + core/wasm/src/lib.rs | 504 +++++++++++ docs/architecture.md | 166 ++-- docs/deployment.md | 29 +- docs/export-and-runtime.md | 13 +- docs/extending.md | 59 +- docs/gotchas.md | 33 +- docs/host-page.md | 15 +- docs/pet-simulation.md | 77 +- docs/prompt-format.md | 30 +- docs/testing.md | 73 +- package.json | 5 +- server/Cargo.lock | 21 +- server/Cargo.toml | 3 +- server/README.md | 27 +- server/src/bin/contract.rs | 181 +++- server/src/brain.rs | 99 +- server/src/host.rs | 229 ++--- server/src/lib.rs | 4 +- server/src/simulation.rs | 825 +---------------- server/src/world.rs | 267 ------ server/tests/host.rs | 4 +- server/tests/native.rs | 6 +- server/tests/parity.mjs | 150 +++- site/build.sh | 8 +- site/bundle.mjs | 10 +- test/body.test.mjs | 130 ++- test/browser-flow.js | 17 +- test/hosting-flow.js | 5 +- test/parity.mjs | 30 +- test/storage.test.mjs | 9 +- test/wasm-core-proof/README.md | 59 -- test/wasm-core-proof/browser.html | 16 - test/wasm-core-proof/check.mjs | 50 -- test/wasm-core-proof/src/lib.rs | 49 - train/world.py | 6 +- web/app/animation.ts | 100 ++- web/app/body.ts | 57 -- web/app/brain.ts | 8 +- web/app/canvas.ts | 4 +- web/app/care.ts | 170 +--- web/app/commands.ts | 85 +- web/app/config.ts | 9 +- web/app/conversation.ts | 227 +---- web/app/debug.ts | 45 +- web/app/diary.ts | 124 +-- web/app/engine.ts | 59 ++ web/app/game.ts | 42 +- web/app/hosting.ts | 65 +- web/app/lifecycle.ts | 16 +- web/app/pwa.ts | 86 +- web/app/save-format.ts | 81 +- web/app/simulation.ts | 201 +---- web/app/storage.ts | 7 +- web/app/store.ts | 8 +- web/app/text.ts | 26 +- web/app/utils.ts | 6 +- web/brain.ts | 76 +- web/components/Chat.tsx | 3 +- web/components/Debug.tsx | 30 +- web/components/Device.tsx | 4 +- web/core.ts | 177 ++++ web/main.tsx | 10 +- web/types.ts | 11 +- web/world.ts | 239 ----- 82 files changed, 4794 insertions(+), 3229 deletions(-) create mode 100644 core/Cargo.lock create mode 100644 core/Cargo.toml create mode 100644 core/README.md create mode 100644 core/src/body.rs create mode 100644 core/src/calendar.rs create mode 100644 core/src/engine.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/save.rs create mode 100644 core/src/turn.rs rename {server => core}/src/types.rs (59%) create mode 100644 core/src/world.rs create mode 100644 core/tests/rules.rs rename {test/wasm-core-proof => core/wasm}/Cargo.lock (87%) rename {test/wasm-core-proof => core/wasm}/Cargo.toml (55%) create mode 100755 core/wasm/build.sh create mode 100644 core/wasm/src/lib.rs delete mode 100644 server/src/world.rs delete mode 100644 test/wasm-core-proof/README.md delete mode 100644 test/wasm-core-proof/browser.html delete mode 100644 test/wasm-core-proof/check.mjs delete mode 100644 test/wasm-core-proof/src/lib.rs delete mode 100644 web/app/body.ts create mode 100644 web/app/engine.ts create mode 100644 web/core.ts delete mode 100644 web/world.ts diff --git a/.gitignore b/.gitignore index 9cad010..b7486c0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ logs/ node_modules/ dist/ server/target/ -test/wasm-core-proof/target/ -test/wasm-core-proof/pkg/ *.sqlite *.sqlite-shm *.sqlite-wal +core/target/ +core/wasm/target/ +core/wasm/pkg/ diff --git a/AGENTS.md b/AGENTS.md index e56260d..6fa9ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,9 @@ fine-tune of GPT-2 (the original 124M "small" model). The page, the pet's 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. +pet. The browser keeps a recovery checkpoint. Both modes run the same rules: +one Rust crate (`core/`), linked natively by the host and compiled to +WebAssembly for the browser. ## Start here @@ -15,8 +17,9 @@ 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` | +| Shared rules: prompt, body, care, turns, saves | `core/README.md` | `core/src/`, `core/wasm/`, `web/core.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` | +| Prompt header, tool grammar, lookups | `docs/prompt-format.md` | `train/world.py`, `core/src/world.rs` | | 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/` | | Teacher data, training, evals | `docs/training.md` | `train/gen_data.py`, `train/mix_data.py`, `train/train.py`, `train/eval_pet.py` | @@ -31,22 +34,36 @@ Read the area document before editing a subsystem. ### The prompt format is a contract with the weights The model only understands what it saw in training. `train/world.py` and -`web/world.ts` render the same state header, event lines and lookup results, -and `node test/parity.mjs` must report **0 mismatches**. Any change to the -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. A host runs one model for all its pets and -refuses to start while a hosted pet selects another brain. +`core/src/world.rs` render the same state header, event lines and lookup +results, and `node test/parity.mjs` (the browser's WebAssembly build) and +`node server/tests/parity.mjs` (the host's native build) must report +**0 mismatches**. Any change to the 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. + +Moving execution does not authorize a format change or an implicit brain +upgrade. A host runs one model for all its pets and refuses to start while a +hosted pet selects another brain. + +### Shared rules live in core, once + +Every rule the browser and the host both apply lives in `core/`: the prompt +contract, stop and context-fit rules, the lookup loop and text filters, the +body, care and offers, the brain's tag effects, the day log and diary, turn +scheduling and save parsing. `server/` links it natively; `core/wasm/` exports +it to `web/core.ts` for the page and its worker. Do not reimplement a rule in +TypeScript or in host code; add it to `core/` and call it from both. Runtimes +own only their logistics: storage, networking, locks, rendering, inference +and when to call the engine. `server/tests/parity.mjs` replays one engine +scenario in both builds (two time zones) and requires identical results. +Calendar time is the one platform difference: the host converts with the +save's time zone, the browser uses its device clock. ### Tags are a closed grammar The pet may only emit the tags in `docs/prompt-format.md`, with values from the -fixed lists in `world.py`/`world.ts`. The data generator rejects anything else, +fixed lists in `world.py` and `core/src/world.rs`. The data generator rejects anything else, and the app ignores anything outside the lists. Do not add parsing that tolerates invented tags; fix the data instead. @@ -73,8 +90,8 @@ the training data's results match the app's. ### Prompts end at `Name:` with no trailing space GPT-2 merges the space into the next token. A bare trailing space derails -sampling (every line started with "ick"). `web/app/conversation.ts` and -`train/eval_pet.py` both build the prefix as `Name:` plus any text so far. +sampling (every line started with "ick"). `core/src/turn.rs` (both runtimes) and +`train/eval_pet.py` build the prefix as `Name:` plus any text so far. ### No repetition penalty @@ -117,7 +134,7 @@ Sleeping lines contain one `[dream:X]` and at most four mumbled words. The only exception is an offer: a dream plus `[no]` stays asleep; a short `[yes]` line wakes it after the answer. Offers are the brain's decision, never a body roll. Messages, pats and lights on use the shared energy/persistence wake -formula. Keep Python's sampled outcomes and the browser formula in parity. +formula. Keep Python's sampled outcomes and the core formula in parity. Ignore other effects while asleep; do not add canned dream content. ### Offers and silent lines @@ -127,13 +144,13 @@ An offer event line (`* Sam offered Mochi a cookie *`, a bath, medicine, one `[yes]` or `[no]`; the app applies the offer only on `[yes]` and treats a missing answer as no. A pet line made only of tags is a "silent" line (the pet acting on its own). Training data allows at most two in a row and the app -keeps at most the newest two consecutive ones in `S.chat`, so idling never +keeps at most the newest two consecutive ones in the chat, so idling never pushes the conversation out of the context. ### Saves are never overwritten blindly `load()` migrates older saves step by step (`VERSION`, `MIGRATIONS` in -`web/app/save-format.ts`) and shows the "bad egg" for anything it cannot read, including a +`core/src/save.rs`) and shows the "bad egg" for anything it cannot read, including a save from a newer version; nothing is saved until the owner downloads it, reloads or starts anew (which moves it to `tokenling.bad`). Only the tab holding the Web Lock runs and saves the pet. Changing a stored shape means a @@ -166,7 +183,7 @@ progress with its eventual response. ### Keep it cute -Pet text is filtered through `NOT_CUTE` in `web/app/text.ts` before display, because +Pet text is filtered through `NOT_CUTE` in `core/src/world.rs` before display, because GPT-2's base weights can produce explicit text. Keep the filter and the retry. Do not hardcode real people's names or details in training data, fixtures or docs; the sample owner and pet names are generic. @@ -180,6 +197,7 @@ describe it are updated **in the same commit**: | If you change… | Update | |---|---| | prompt header, tags, word lists, stop rules | `docs/prompt-format.md`, and `docs/extending.md` if the recipe changes | +| shared rules or their boundary (`core/`, `web/core.ts`) | `core/README.md`, and the doc for the rule's area | | simulation rates, stages, storage fields, UI behavior | `docs/pet-simulation.md` | | data generation, validation, training settings, evals | `docs/training.md` (including the dataset stats if you regenerate) | | export, quantization, runtime version, brain loading or fallbacks | `docs/export-and-runtime.md` | @@ -201,16 +219,21 @@ the state and worker protocol typed, and preserve `window.tokenling` debug controls for browser checks. Animation frames update the canvas, not the component tree. -Before committing, run `npm run format:check`, `npm run typecheck` and `node --test test/*.test.mjs`, re-read the docs you touched against the code, run -`node test/parity.mjs` and `node test/contract.mjs` and `python3 test/data_rules.py`, and check that relative links and `web/`, `train/`, -`test/` and `docs/` paths in the docs still exist. State only what you +Before committing, run `npm run format:check`, `npm test` (it builds the +WebAssembly rules first, then types, parity, contract, Node tests, data and +script rules), re-read the docs you touched against the code, and check that +relative links and `core/`, `web/`, `train/`, `test/` and `docs/` paths in the +docs still exist. For `core/` changes also run +`cargo fmt --check --manifest-path core/Cargo.toml` (and `core/wasm/Cargo.toml`) +and `cargo test --locked --manifest-path core/Cargo.toml`. State only what you 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`, +For host or `core/` 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 +`tokenling-contract` binary and run `node server/tests/parity.mjs` (Python +parity plus the native/WebAssembly engine replay). 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. @@ -231,8 +254,9 @@ doc, and test a new script against a stand-in before relying on it. ```sh npm ci # install pinned frontend dependencies (Node.js 24+) -npm test # types, parity, UI contract, body/save, data and script rules -node test/parity.mjs # JS and Python prompts agree +npm run core # build the shared rules to WebAssembly (core/wasm/pkg) +npm test # core build, types, parity, UI contract, engine/save, data and script rules +node test/parity.mjs # the core's WebAssembly build and Python prompts agree $VENV/bin/python train/train.py --help # training environment (torch + CUDA) $VENV/bin/python train/eval_pet.py CHECKPOINT 40 python3 train/mix_data.py OLD.jsonl NEW.jsonl OUT.jsonl # reuse older rows the format still accepts @@ -246,9 +270,12 @@ train/teachers.sh start|wait|stop # teacher servers for data generation Datasets, checkpoints, ONNX exports and logs (`$DATA`) stay out of Git. The browser app uses Preact with strict TypeScript. Run `npm ci` (Node.js 24+) -once before building. `site/build.sh` checks types, bundles the app and worker -with esbuild, fills the host page template and copies assets. The repository -must build and deploy on its own, with no host-specific tooling. +once before building; building also needs Rust 1.88+ with the +`wasm32-unknown-unknown` target and `wasm-bindgen-cli` at the version +`core/wasm/Cargo.lock` pins. `site/build.sh` builds the shared rules to +WebAssembly, checks types, bundles the app and worker with esbuild, fills the +host page template and copies assets. Generated `core/wasm/pkg/` stays out of +Git. The repository must build and deploy on its own, with no host-specific tooling. ## Working agreements diff --git a/README.md b/README.md index d4539a7..2f8f91f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ 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. +Both modes run the same rules. The prompt contract, body, care, the brain's +tag effects, turns, diary and saves are one Rust crate ([`core/`](core/README.md)), +linked natively by the host and compiled to WebAssembly for the page. + ## The pet's tools GPT-2 was fine-tuned to call tools inline in its own lines. The app parses @@ -49,7 +53,7 @@ A line of only tags (`Mochi: [walk:left] [emote:happy]`) is the pet acting on its own; the app asks for one every few seconds. Every prompt starts with a state header, rendered identically by -`train/world.py` and `web/world.ts`: +`train/world.py` (training) and `core/src/world.rs` (the app and host): ``` ## tokenling chat @@ -70,6 +74,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 | +| [`core/README.md`](core/README.md) | The shared Rust rules: what lives there, the WebAssembly build and native/browser parity | | [`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 | @@ -86,19 +91,21 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] | Path | Contents | |---|---| | `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` | +| `core/`, `core/wasm/` | shared Rust rules (prompt contract, body, care, tags, turns, diary, saves) and their WebAssembly bindings; `core/wasm/build.sh` writes `core/wasm/pkg/` (out of Git) | +| `web/core.ts`, `web/app/engine.ts` | typed access to the WebAssembly rules; the browser's engine and its read-only `store.state` snapshot | +| `web/app/` | typed state, browser storage, animations and effects, care actions, the turn runner, diary writer, 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 | +| `server/` | Rust/Axum/SQLite host linking `core/`, native ONNX workers and ownership/API/parity tests | | `server/src/routing.rs` | bounded timing estimates, queue completion planning and background aging | | `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 | +| `web/scene.ts`, `web/sprites.ts` | pixel LCD and sprite icons | | `package.json`, `package-lock.json`, `tsconfig.json` | pinned Preact, TypeScript, esbuild and Prettier dependencies; strict type checking | | `site/index.html`, `site/assets/` | the page (a template with `@PLACEHOLDERS@`), its font, web app manifest and icons | | `site/sw.js` | service worker: offline copies of the page, app and runtime; alerts | | `site/build.sh`, `site/bundle.mjs`, `site/release.json` | builds the static site from the release description (current and older models) | | `site/make-icons.mjs` | draws the app icons in `site/assets/` | -| `train/world.py` | prompt format, tool results and event lines, mirrored by `web/world.ts` | +| `train/world.py` | prompt format, tool results and event lines for training; `core/src/world.rs` renders the same strings | | `train/gen_data.py`, `train/teachers.sh` | synthetic chat/diary data from a teacher model, validated tag by tag | | `train/migrate_v6.py` | migrates old chat headers and sleep events, rebuilds loss spans and replaces old teen rows | | `train/mix_data.py` | reuses an older dataset's rows that the current format still accepts | @@ -107,20 +114,22 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] | `train/export.sh`, `train/quantize.py` | ONNX export and 4-bit quantization for WebAssembly | | `train/vendor.sh`, `train/publish.sh` | pinned browser runtime and static publishing | | `train/check_onnx.py` | greedy-decode check of an export against torch | -| `test/parity.mjs`, `test/parity_states.py` | JS/Python prompt parity | +| `test/parity.mjs`, `test/parity_states.py` | WebAssembly core/Python prompt parity | | `test/data_rules.py` | training-contract and migration regression cases, including a stand-in sleep teacher | | `test/site_tools.py` | stdlib regressions for safe build/publish failures, immutable artifacts and preview runtime refresh | | `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/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | body, engine and save rules through the WebAssembly build, 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 | | `server/tests/native.rs`, `test/host-startup.py` | opt-in real native CPU/GPU/private-HTTP checks and actual CPU fallback at startup | -| `test/wasm-core-proof/` | opt-in build/interop proof of the existing Rust prompt/lookup code in JavaScript | | `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. +The frontend uses Preact and strict TypeScript. `site/build.sh` builds the +shared rules to WebAssembly, type-checks, bundles the app and worker with +esbuild, then fills the static host template. Building needs Node.js 24+ and +Rust 1.88+ with the `wasm32-unknown-unknown` target and the pinned +`wasm-bindgen-cli` (see [`core/README.md`](core/README.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). @@ -128,9 +137,9 @@ See [`docs/deployment.md`](docs/deployment.md) and [`server/README.md`](server/R ```sh npm ci # Node.js 24 or newer -npm test # types, parity, UI contract, body/save and data rules +npm test # WebAssembly rules build, types, parity, UI contract, engine/save and data rules npm run format:check # consistent TS/TSX formatting -node test/parity.mjs # JS and Python prompts agree +node test/parity.mjs # the shared rules and Python prompts agree node test/contract.mjs # app, reference page and docs agree on element ids site/build.sh /tmp/site # the static site (add models/ and runtime/ with train/publish.sh) diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 0000000..2230c9a --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,291 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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 = [ + "num-traits", +] + +[[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 = "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 = "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 = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "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 = "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 = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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", +] + +[[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 = "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 = "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 = "tokenling-core" +version = "0.1.0" +dependencies = [ + "chrono", + "chrono-tz", + "js-sys", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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", + "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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..21a8c33 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tokenling-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT" +publish = false +description = "tokenling's shared rules: prompt contract, body, care, turns and saves" + +[dependencies] +regex-lite = "0.1.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# The host reads each save's time zone; the browser uses the device's own clock. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +chrono = { version = "0.4", default-features = false, features = ["std"] } +chrono-tz = "0.10.4" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "=0.3.106" diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..cae2c2c --- /dev/null +++ b/core/README.md @@ -0,0 +1,75 @@ +# tokenling shared rules + +`core/` is one Rust crate, `tokenling-core`, holding every rule the browser +and the optional Rust host both apply. The host links it natively +(`server/`); `core/wasm/` compiles it to WebAssembly for the page and its +brain worker (`web/core.ts`). A pet therefore behaves the same in local and +remote mode, and a transfer moves the same save between two copies of the +same engine. `train/world.py` stays an independent Python rendering of the +prompt contract for training; parity checks compare it with both builds. + +The crate does no I/O. Callers pass the time, a random source and the +deployment's weather seed. Storage, networking, Web Locks, rendering, game +physics and inference stay in each runtime, which only decides **when** to +call the engine. + +## What lives here + +| Module | Rules | +|---|---| +| `src/world.rs` | The trained prompt contract: closed word lists, chat/diary headers, lookups (`[clock]`, `[look]`, `[recall:…]`, `[yesterday]`), event lines, the tag grammar, `NOT_CUTE`, text cleaning, stop rules (`stop`, `lookup_tail`) and context fitting (`fit`, given the brain's token counter). | +| `src/types.rs` | Save version 2 and the prompt's world state, with save validation. Unknown fields survive a round trip. | +| `src/save.rs` | New saves, `VERSION`, `MIGRATIONS` (v1 → v2) and reading a stored or imported save; refusals carry `newer` for the bad egg. | +| `src/calendar.rs` | Local date and hour. Natively: the save's `hosting.timeZone` (chrono-tz). In WebAssembly: the device clock (`Date`), so a local pet follows its owner's current zone. Moving to a host records that zone first. | +| `src/body.rs` | Needs, poop, grime and sickness, collapsing and waking, growth stages, weather, the one-week catch-up cap and the alert projection. | +| `src/turn.rs` | Brain requests (`Generation`: header, lines, prefix, budgets, temperature 0.85, top-k 40, top-p 0.92), the chat line loop with up to three lookups and three filtered tries, and diary pages (doodles, paragraphs, dropping unsafe pages). | +| `src/engine.rs` | A running pet: the save plus its turn queue, disturbances, effects and accepted game. Owner commands (the host's JSON), the brain's tag effects, offers, the day log, dreams, silent-line trimming, turn scheduling (react, bedtime diary, idle, missed pages), commits and the "came back" notice. | + +`Engine` is serialized flattened into the host's stored rows, so their +layout did not change when the engine moved here. In the browser the engine +lives inside the WebAssembly module; `web/app/engine.ts` keeps a read-only +snapshot in `store.state`, refreshing only the body fields on a plain tick. + +Effects (`emote`, `walk`, `want`, `dream`, `eat`, `bath`, `pat`, `sparkle`, +`gift`, `remember`, `grew`, `sick`, `diary`, `sleep`, `woke`) are for the LCD +and notices only. The save has already changed. Local changes and host +snapshots play them through the same `playEffect()`. + +## Build and checks + +Use Rust 1.88+. The browser build needs the `wasm32-unknown-unknown` target +and `wasm-bindgen-cli` at exactly the version `core/wasm/Cargo.lock` pins; +`core/wasm/build.sh` checks both and fails with the fix. + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.129 --locked +core/wasm/build.sh # core/wasm/pkg: module, ES glue, .d.ts (out of Git) +cargo fmt --check --manifest-path core/Cargo.toml +cargo fmt --check --manifest-path core/wasm/Cargo.toml +cargo test --locked --manifest-path core/Cargo.toml +node test/parity.mjs # WebAssembly build vs train/world.py +cargo build --locked --manifest-path server/Cargo.toml --bin tokenling-contract +node server/tests/parity.mjs # native build vs Python, then native vs WebAssembly replay +``` + +`site/build.sh` runs `core/wasm/build.sh` and copies +`tokenling_core_bg.wasm` into the content-hashed `app//` directory beside +`main.js` and `brain.js`. Both bundles load it from there, it enters the app +hash, and the page lists it for offline use. The page shows an error instead +of the app if the module cannot load. + +`server/tests/parity.mjs` replays a 31-step scenario natively (through +`tokenling-contract`) and in WebAssembly with the same random draws. It covers +chat with lookups and a filtered retry, offers, catch, an absence, sleep, +dreams, both diary paths and growth. It runs in UTC and in a zone with a +daylight-saving change, and requires identical engine JSON after every step. + +## Size and speed (2026-10-10) + +Measured on one desktop with Node.js 24; not phone or download measurements. +The release module is 589,730 bytes (186,689 with gzip). Node instantiated it +in about 2–3 ms. Reading a deliberately large state (240 chat lines, 60 +memories, 365 diary pages) took about 1.1–1.4 ms; the body-only patch read per +tick about 0.01 ms, and one body advance about 0.01 ms (averages of 50 runs, +two runs). diff --git a/core/src/body.rs b/core/src/body.rs new file mode 100644 index 0000000..373de3b --- /dev/null +++ b/core/src/body.rs @@ -0,0 +1,202 @@ +//! The pet's body: needs, poop, sickness, collapsing and waking, growth and +//! weather. Choices stay in the brain; this is only physiology and time. +use crate::{calendar, types::PetState, world}; + +pub const MIN: f64 = 60_000.0; +pub const HOUR: f64 = 60.0 * MIN; +pub const DAY: f64 = 24.0 * HOUR; +/// Catch-up after an absence simulates at most this long. +pub const MAX_CATCH_UP: f64 = 7.0 * DAY; + +pub fn clamp(n: f64) -> f64 { + n.clamp(0.0, 100.0) +} +pub fn local(state: &PetState, t: f64) -> calendar::Local { + calendar::local(&state.hosting.time_zone, t) +} +pub fn date_key(state: &PetState, t: f64) -> String { + local(state, t).date_key() +} +pub fn is_night(state: &PetState, t: f64) -> bool { + world::part_of_day(local(state, t).hour) == "night" +} +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) +} +/// Growth stages and the age each starts at, oldest first. +pub const STAGE_AGES: [(&str, f64); 4] = [ + ("adult", 5.0 * DAY), + ("teen", 2.0 * DAY), + ("kid", 12.0 * HOUR), + ("baby", 0.0), +]; +pub fn stage_for(age: f64) -> &'static str { + STAGE_AGES + .iter() + .find(|(_, start)| age >= *start) + .map_or("baby", |(stage, _)| stage) +} +/// Weather is a hash of the date, day or night, and the deployment's seed. +pub fn weather_at(state: &PetState, t: f64, seed: &str) -> String { + let d = local(state, t); + let night = world::part_of_day(d.hour) == "night"; + let text = d.date_key() + if night { "n" } else { "d" } + seed; + let mut hash = 2166136261u32; + // Each code point contributes its first UTF-16 unit (the original JS hash). + 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() +} + +/// Advances needs from `from` to `to`. `quiet` steps in 10-minute slices for +/// catch-up and projections. Returns the body events that happened. +pub fn simulate_body( + state: &mut PetState, + from: f64, + to: f64, + quiet: bool, + random: &mut dyn FnMut() -> f64, +) -> Vec<&'static str> { + 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"); + } + 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 { + // Sleep pauses grime exposure. + 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"); + } + // Choosing to nap is the brain's call ([sleep]); collapsing is the + // body's, but not within 5 minutes of being woken up. + if !state.asleep && state.stats.energy < 6.0 && t - state.woke_at > 5.0 * MIN { + state.asleep = true; + events.push("fell asleep"); + } + let hr = local(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"); + } + if state.stats.tummy < 15.0 { + events.push("hungry"); + } + if state.stats.clean < 20.0 { + events.push("stinky"); + } + } + events +} + +/// A body alert the browser may show while its page is closed. +#[derive(Clone, Debug, serde::Serialize)] +pub struct Alert { + pub kind: &'static str, + pub at: f64, + pub stage: Option<&'static str>, +} +/// Projects a copy of the body a day ahead in 10-minute steps and returns up +/// to four first occurrences: hungry, poop, sick and growing up. +pub fn alert_plan(state: &PetState, t: f64, random: &mut dyn FnMut() -> f64) -> Vec { + let Some(pet) = &state.pet else { + return vec![]; + }; + let mut state = state.clone(); + let mut items: Vec = vec![]; + let add = |items: &mut Vec, kind, at, stage| { + if !items.iter().any(|i| i.kind == kind) { + items.push(Alert { kind, at, stage }); + } + }; + let (hungry0, sick0) = (state.stats.tummy < 20.0, state.sick); + let (mut poop, mut stage) = (state.poop, pet.stage.clone()); + let mut now = t; + while now < t + DAY && items.len() < 4 { + simulate_body(&mut state, now, now + 10.0 * MIN, true, random); + let at = now + 10.0 * MIN; + if !hungry0 && state.stats.tummy < 20.0 { + add(&mut items, "hungry", at, None); + } + if state.poop > poop { + add(&mut items, "poop", at, None); + } + if !sick0 && state.sick { + add(&mut items, "sick", at, None); + } + let st = stage_for(at - pet.hatched); + if st != stage { + stage = st.into(); + add(&mut items, "grew", at, Some(st)); + } + poop = state.poop; + now += 10.0 * MIN; + } + items +} diff --git a/core/src/calendar.rs b/core/src/calendar.rs new file mode 100644 index 0000000..b75da92 --- /dev/null +++ b/core/src/calendar.rs @@ -0,0 +1,62 @@ +//! Local calendar time for the body, weather and the prompt clock. +//! +//! The host converts with the save's `hosting.timeZone`. The browser build +//! reads the device's own clock (`Date`), so a local pet follows its owner's +//! current zone; moving to a host records that zone first. + +/// Calendar fields at one instant; `month0` counts from 0 as JavaScript does. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Local { + pub year: i32, + pub month0: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, +} +impl Local { + pub fn date_key(&self) -> String { + format!("{:04}-{:02}-{:02}", self.year, self.month0 + 1, self.day) + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn local(time_zone: &str, t: f64) -> Local { + use chrono::{Datelike, TimeZone, Timelike}; + let tz = time_zone.parse::().unwrap_or(chrono_tz::UTC); + let d = tz + .timestamp_millis_opt(t as i64) + .single() + .unwrap_or_else(|| tz.timestamp_millis_opt(0).unwrap()); + Local { + year: d.year(), + month0: d.month0(), + day: d.day(), + hour: d.hour(), + minute: d.minute(), + } +} +#[cfg(not(target_arch = "wasm32"))] +pub fn valid_zone(time_zone: &str) -> bool { + time_zone.parse::().is_ok() +} + +#[cfg(target_arch = "wasm32")] +pub fn local(_time_zone: &str, t: f64) -> Local { + let d = js_sys::Date::new(&t.into()); + Local { + year: d.get_full_year() as i32, + month0: d.get_month(), + day: d.get_date(), + hour: d.get_hours(), + minute: d.get_minutes(), + } +} +/// The browser supplied this zone name itself; check only its shape. +#[cfg(target_arch = "wasm32")] +pub fn valid_zone(time_zone: &str) -> bool { + !time_zone.is_empty() + && time_zone.len() <= 64 + && time_zone + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"/_+-".contains(&b)) +} diff --git a/core/src/engine.rs b/core/src/engine.rs new file mode 100644 index 0000000..412d48e --- /dev/null +++ b/core/src/engine.rs @@ -0,0 +1,846 @@ +//! A running pet: the save plus the short-lived state around it (the turn +//! queue, disturbances, effects to animate, an accepted game). The host and +//! the browser's local mode run this same engine. Owner commands and the +//! brain's tags change it; the brain makes every decision. +use crate::{ + body::{self, DAY, HOUR, MAX_CATCH_UP, MIN, clamp}, + turn::{ChatTurn, DiaryTurn}, + types::*, + world::{self, events}, +}; +use serde::{Deserialize, Serialize}; + +/// Random draws, passed in so both runtimes (and tests) choose the source. +pub trait Dice { + fn roll(&mut self) -> f64; + /// Wake rolls are separate so browser checks can force an outcome. + fn wake(&mut self) -> f64 { + self.roll() + } +} +impl f64> Dice for F { + fn roll(&mut self) -> f64 { + self() + } +} + +/// Something for the LCD to show. Effects never change the save again. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Effect { + pub seq: u64, + pub kind: String, + pub arg: String, +} +/// A catch game the pet accepted; its score commits once, by id. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Game { + pub id: String, + pub started: f64, +} + +#[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, + }, +} + +/// A refused command, worded for the owner. +#[derive(Clone, Debug, PartialEq)] +pub struct RuleError(pub String); +impl std::fmt::Display for RuleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for RuleError {} +fn ensure(ok: bool, why: impl Into) -> Result<(), RuleError> { + if ok { + Ok(()) + } else { + Err(RuleError(why.into())) + } +} + +/// Work the brain should do next. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Task { + /// A pet line: `react` answers something that happened, `idle` lets it live. + Chat { kind: String }, + /// A diary page for `date`. + Diary { date: String }, +} + +pub const MAX_EFFECTS: usize = 32; +/// Owners have left the pet alone this long before a missed page is written. +pub const DIARY_QUIET_MS: f64 = 30_000.0; +/// An accepted game that never reports a score expires. +pub const GAME_EXPIRY: f64 = 5.0 * MIN; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Engine { + pub state: PetState, + /// Bumps on every change; snapshots with a lower revision are older. + pub revision: u64, + /// Bumps whenever the prompt context changes; a reply generated for an + /// older context is stale. + pub context_revision: u64, + /// The turn the pet wants next: "react" or "idle". + pub pending: Option, + pub next_idle: f64, + pub last_turn: f64, + pub game: Option, + pub effects: Vec, + pub effect_seq: u64, + /// Disturbances while asleep; reset on waking, a new sleep or 5 quiet minutes. + pub disturbance_count: u32, + pub disturbance_last: f64, +} + +impl Engine { + pub fn new(state: PetState, t: f64) -> Self { + Self { + state, + revision: 0, + context_revision: 0, + pending: None, + next_idle: t + 4000.0, + last_turn: t, + game: None, + effects: vec![], + effect_seq: 0, + disturbance_count: 0, + disturbance_last: 0.0, + } + } + fn pet_name(&self) -> String { + self.state + .pet + .as_ref() + .map_or_else(String::new, |p| p.name.clone()) + } + 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() > MAX_EFFECTS { + self.effects.remove(0); + } + } + /// Appends a chat line. At most two silent pet lines (only tags) stay in a + /// row, so idling cannot push the conversation out of the context. + pub fn push_line(&mut self, line: String) { + if self.state.pet.is_some() { + let prefix = self.pet_name() + ": "; + let silent = |l: &String| l.strip_prefix(&prefix).is_some_and(world::silent); + if silent(&line) + && self + .state + .chat + .iter() + .rev() + .take(2) + .filter(|l| silent(l)) + .count() + == 2 + { + self.state.chat.remove(self.state.chat.len() - 2); + } + } + self.state.chat.push(line); + if self.state.chat.len() > MAX_CHAT { + self.state.chat.remove(0); + } + self.context_revision += 1; + self.revision += 1; + } + /// At midnight a day with no page yet waits in `history` for the pet to + /// write it. Days with fewer than two things in them get no page, and + /// only the newest week is kept. + pub fn roll_day(&mut self, t: f64, seed: &str) { + let k = body::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) + { + let log = DayLog { + log: self.state.today.log.log.clone(), + weather: Some( + self.state + .today + .log + .weather + .clone() + .unwrap_or_else(|| body::weather_at(&self.state, t - DAY, seed)), + ), + day: Some( + self.state + .today + .log + .day + .unwrap_or_else(|| body::day_num(&self.state, t) - 1), + ), + }; + self.state + .history + .insert(self.state.today.date.clone(), log); + } + while self.state.history.len() > 7 { + self.state.history.pop_first(); + } + self.state.today = Today { + date: k, + log: DayLog { + log: vec![], + weather: Some(body::weather_at(&self.state, t, seed)), + day: Some(body::day_num(&self.state, t)), + }, + }; + self.revision += 1; + } + /// Notes something in today's log; a repeat moves to the end. + pub fn log(&mut self, text: String, t: f64, seed: &str) { + self.roll_day(t, seed); + let log = &mut self.state.today.log.log; + log.retain(|s| *s != text); + log.push(text); + if log.len() > 14 { + log.remove(0); + } + self.revision += 1; + } + /// A react request replaces a waiting idle one. + pub fn want_turn(&mut self, kind: &str) { + if kind == "react" || self.pending.is_none() { + self.pending = Some(kind.into()); + } + } + pub fn reset_disturbances(&mut self) { + self.disturbance_count = 0; + self.disturbance_last = 0.0; + } + 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.reset_disturbances(); + self.effect("woke", ""); + self.push_line(events::woke(&self.pet_name())); + } + /// Talking, touching or the lights may wake a sleeping pet (a body roll). + /// Offers wake it only when the brain accepts. + pub fn disturb(&mut self, kind: &str, t: f64, dice: &mut dyn Dice) -> bool { + if !self.state.asleep || self.state.pet.is_none() { + return false; + } + if t - self.disturbance_last >= 5.0 * MIN { + self.disturbance_count = 0; + } + self.disturbance_count += 1; + self.disturbance_last = t; + let wakes = + dice.wake() < world::wake_chance(self.state.stats.energy, kind, self.disturbance_count); + if wakes { + self.wake(t); + } + wakes + } + fn sleep_effects(&mut self, t: f64) { + self.reset_disturbances(); + self.state.lights_off |= body::is_night(&self.state, t); + self.effect("sleep", ""); + self.context_revision += 1; + } + /// Runs the body from `last_tick` to `t`. Gaps over two minutes are a quiet + /// catch-up (capped at a week). `returning` marks the owner coming back to + /// a closed page, which the pet notices after half an hour. + pub fn advance(&mut self, t: f64, seed: &str, returning: bool, dice: &mut dyn Dice) { + let from = self.state.last_tick; + if t <= from { + return; + } + let away = t - from; + let quiet = away > 2.0 * MIN; + let happened = body::simulate_body( + &mut self.state, + from, + t.min(from + MAX_CATCH_UP), + quiet, + &mut || dice.roll(), + ); + let has = |e: &str| happened.contains(&e); + self.state.last_tick = t; + self.revision += 1; + self.roll_day(t, seed); + if self + .game + .as_ref() + .is_some_and(|g| t - g.started > GAME_EXPIRY) + { + self.game = None; + } + let Some(pet) = self.state.pet.clone() else { + return; + }; + let stage = body::stage_for(t - pet.hatched); + if pet.stage != stage { + self.state.pet.as_mut().unwrap().stage = stage.into(); + self.effect("grew", stage); + self.push_line(events::grew(&pet.name, stage)); + self.want_turn("react"); + } + if has("pooped") { + self.log("pooped".into(), t, seed); + if !quiet { + self.push_line(events::pooped(&pet.name)); + self.want_turn("react"); + } + } + if has("sick") && !quiet { + self.effect("sick", ""); + } + if has("fell asleep") || (quiet && has("woke")) { + self.log("napped".into(), t, seed); + } + if has("fell asleep") && self.state.asleep { + self.sleep_effects(t); + } + if returning && away > 30.0 * MIN { + let span = if away > 2.0 * DAY { + format!("{} days", (away / DAY).floor()) + } else if away > 2.0 * HOUR { + format!("{} hours", (away / HOUR).floor()) + } else { + format!("{} minutes", (away / MIN).floor()) + }; + self.push_line(events::back(&self.state.owner, &span)); + self.log( + format!("{} was away for a while", self.state.owner), + t, + seed, + ); + self.want_turn("react"); + } + if has("woke") && !self.state.asleep { + self.reset_disturbances(); + self.effect("woke", ""); + self.push_line(events::woke(&pet.name)); + self.want_turn("react"); + } + for (low, entry) in [ + (self.state.stats.tummy < 15.0, "was very hungry"), + (self.state.stats.clean < 20.0, "was stinky"), + ] { + if low && !self.state.today.log.log.iter().any(|s| s == entry) { + self.log(entry.into(), t, seed); + } + } + // An offer left open while nobody ran the pet has gone stale. + if quiet && self.pending.is_none() { + self.state.offer = None; + } + } + /// The prompt's view of the pet at `t`. + pub fn world(&self, t: f64, seed: &str) -> WorldState { + let p = self.state.pet.as_ref().expect("an egg has no world state"); + let d = body::local(&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: body::day_num(&self.state, t), + traits: p.traits.clone(), + hh: d.hour, + mm: d.minute, + weather: body::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, + } + } + /// The owner names themselves and gets an egg (setup, before hatching). + pub fn lay_egg(&mut self, owner: &str, t: f64) -> Result<(), RuleError> { + let owner = world::clean_name(owner, 24); + ensure(!owner.is_empty(), "Your name is required.")?; + self.state.owner = owner; + if self.state.pet.is_none() && self.state.egg.is_none() { + self.state.egg = Some(Egg { laid: t }); + } + self.revision += 1; + Ok(()) + } + pub fn command( + &mut self, + command: Command, + t: f64, + seed: &str, + dice: &mut dyn Dice, + ) -> Result<(), RuleError> { + self.advance(t, seed, false, dice); + let owner = self.state.owner.clone(); + let pet = self.pet_name(); + match command { + Command::Hatch { name } => { + ensure( + self.state.pet.is_none() && self.state.egg.is_some(), + "There is no egg to hatch.", + )?; + let name = world::clean_name(&name, 16); + ensure(!name.is_empty(), "The pet needs a name.")?; + let mut traits: Vec<_> = world::PERSONALITIES.to_vec(); + for i in (1..traits.len()).rev() { + traits.swap(i, pick_index(dice, i + 1)); + } + self.state.pet = Some(Pet { + name, + traits: traits[..2].iter().map(|s| s.to_string()).collect(), + color: world::COLORS[pick_index(dice, world::COLORS.len())].into(), + toy: world::TOYS[pick_index(dice, world::TOYS.len())].into(), + hatched: t, + stage: "baby".into(), + }); + self.state.egg = None; + self.state.today = Today { + date: body::date_key(&self.state, t), + log: DayLog { + log: vec![], + weather: Some(body::weather_at(&self.state, t, seed)), + day: Some(1), + }, + }; + self.state.next_poop = t + 3.0 * HOUR; + self.effect("sparkle", ""); + self.want_turn("react"); + } + Command::Message { text } => { + ensure( + self.state.pet.is_some() && self.game.is_none(), + format!("{pet} can't listen right now."), + )?; + let text = world::clean_message(&text); + ensure(!text.is_empty(), "The message is empty.")?; + self.disturb("talk", t, dice); + self.push_line(format!("{owner}: {text}")); + if text.chars().count() > 12 && dice.roll() < 0.5 { + let said: String = text.chars().take(48).collect(); + self.log(format!("{owner} said: {}", said.to_lowercase()), t, seed); + } + self.want_turn("react"); + } + Command::Care { kind, item } => { + ensure( + self.state.pet.is_some() && self.game.is_none(), + format!("{pet} can't be looked after right now."), + )?; + match kind.as_str() { + "feed" | "bath" | "medicine" | "play" => { + if let Some(open) = &self.state.offer { + return Err(RuleError(format!( + "{pet} is still deciding about {}.", + offer_thing(open) + ))); + } + let line = match kind.as_str() { + "feed" => { + let food = item.as_deref().unwrap_or(""); + ensure(world::FOODS.contains(&food), "That isn't a food.")?; + events::offer_food(&owner, &pet, food) + } + "bath" => events::offer_bath(&owner, &pet), + "medicine" => events::offer_medicine(&owner, &pet), + _ => events::offer_catch(&owner, &pet), + }; + let item = if kind == "feed" { item } else { None }; + self.state.offer = Some(Offer { kind, item }); + self.push_line(line); + } + "pat" => { + // The squish is physics; the feelings come from the brain. + self.effect("pat", if self.state.asleep { "asleep" } else { "" }); + self.disturb("touch", t, dice); + self.state.stats.fun = clamp(self.state.stats.fun + 4.0); + self.state.counts.pats += 1; + // While a reaction to the last pat is pending, more pats just squish. + let line = events::pat(&owner, &pet); + if self.state.chat.last() != Some(&line) { + self.push_line(line); + self.log("got patted".into(), t, seed); + } + } + "clean" => { + ensure(self.state.poop > 0, "All clean already.")?; + self.state.poop = 0; + self.state.stats.clean = clamp(self.state.stats.clean + 10.0); + self.effect("sparkle", ""); + self.push_line(events::cleaned(&owner)); + } + "lights" => { + self.state.lights_off = !self.state.lights_off; + self.push_line(events::lights(&owner, !self.state.lights_off)); + if !self.state.lights_off { + self.disturb("light", t, dice); + } + } + _ => return Err(RuleError("Unknown care action.".into())), + } + self.want_turn("react"); + } + Command::Rename { name } => { + let name = world::clean_name(&name, 24); + ensure(!name.is_empty(), "Your name is required.")?; + self.state.owner = name; + } + Command::Chatter { value } => { + self.state.settings.chatter = value; + self.schedule_idle(t, dice); + } + Command::ForgetMemory { index, fact } => { + ensure( + self.state.memories.get(index) == Some(&fact), + "That memory changed; look again 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), + "That game is over.", + )?; + ensure(score <= 60, "That score is impossible.")?; + self.complete_game(score, t, seed); + } + } + self.state.last_active = t; + self.context_revision += 1; + self.revision += 1; + Ok(()) + } + fn complete_game(&mut self, n: u32, t: f64, seed: &str) { + self.game = None; + 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(events::caught(&self.state.owner, &self.pet_name(), n)); + self.log(format!("played catch ({n} {})", events::stars(n)), t, seed); + self.want_turn("react"); + } + /// The brain's answer to the open offer; only `yes` applies it. + pub fn answer_offer(&mut self, yes: bool, t: f64, seed: &str, dice: &mut dyn Dice) { + let Some(offer) = self.state.offer.take() else { + return; + }; + self.revision += 1; + if !yes { + self.log(format!("said no to {}", offer_thing(&offer)), t, seed); + return; + } + self.wake(t); + match offer.kind.as_str() { + "feed" => { + let food = offer.item.unwrap_or_default(); + let treat = ["pudding", "cookie"].contains(&food.as_str()); + self.state.stats.tummy = + clamp(self.state.stats.tummy + if treat { 22.0 } else { 28.0 }); + if treat || food == "strawberry" { + 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" => { + // Medicine has no effect on a healthy pet. + if self.state.sick { + self.state.sick = false; + self.state.grime_since = 0.0; + self.effect("sparkle", ""); + } + self.log("took medicine".into(), t, seed); + } + _ => { + let id = (0..4) + .map(|_| format!("{:08x}", (dice.roll() * 4294967296.0) as u32)) + .collect(); + self.game = Some(Game { id, started: t }); + } + } + } + /// Effects of the pet's own tags in a committed line. + pub fn apply_line(&mut self, line: &str, t: f64, seed: &str, dice: &mut dyn Dice) { + let sleeping = self.state.asleep; + let tags = world::tags(line); + let answers: Vec<_> = tags + .iter() + .filter(|tag| world::ANSWER_TOOLS.contains(&tag.name.as_str())) + .collect(); + // Resolve the answer once, independent of tag order; ambiguous answers do nothing. + if self.state.offer.is_some() + && answers.len() == 1 + && answers[0].arg.is_none() + && answers[0].result.is_none() + { + self.answer_offer(answers[0].name == "yes", t, seed, dice); + } + for tag in tags { + let arg = tag.arg.unwrap_or_default(); + if tag.result.is_some() && world::EFFECT_TOOLS.contains(&tag.name.as_str()) { + continue; + } + if tag.name == "dream" { + if sleeping && self.state.asleep && world::DREAMS.contains(&arg.as_str()) { + self.dream(&arg, t, seed); + } + continue; + } + // A sleeping answer can only dream or accept/refuse the offer, even if + // accepting it has just woken the pet. Other effects wait for next turn. + if sleeping || self.state.asleep { + continue; + } + match tag.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(); + let known = self + .state + .memories + .iter() + .any(|s| s.to_lowercase() == fact.to_lowercase()); + if !known { + self.state.memories.push(fact.clone()); + if self.state.memories.len() > MAX_MEMORIES { + self.state.memories.remove(0); + } + self.effect("remember", &fact); + 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() > MAX_SHELF { + 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); + } + _ => {} + } + } + // An offer the pet saw but did not answer counts as no. + if self.state.offer.is_some() { + self.answer_offer(false, t, seed, dice); + } + self.revision += 1; + } + fn dream(&mut self, item: &str, t: f64, seed: &str) { + self.effect("dream", item); + self.roll_day(t, seed); + let entry = format!("dreamed of {}", world::dream_thing(item)); + if self.state.today.log.log.contains(&entry) { + return; + } + // Keep at most two dreams, so frequent dreams cannot displace the whole day. + let dreams: Vec<_> = self + .state + .today + .log + .log + .iter() + .filter(|s| s.starts_with("dreamed of ")) + .cloned() + .collect(); + if dreams.len() >= 2 { + self.state.today.log.log.retain(|s| *s != dreams[0]); + } + self.log(entry, t, seed); + } + /// The next idle turn: minutes while asleep or with chatter off. + pub fn schedule_idle(&mut self, t: f64, dice: &mut dyn Dice) { + self.next_idle = t + if self.state.asleep { + (3.0 + dice.roll() * 3.0) * MIN + } else if self.state.settings.chatter { + 8000.0 + dice.roll() * 12000.0 + } else { + (2.0 + dice.roll() * 2.0) * MIN + }; + } + /// What the brain should do now. Without `background` only a requested + /// reaction runs (the browser while its page is hidden). + pub fn choose_task(&self, t: f64, background: bool) -> Option { + self.state.pet.as_ref()?; + if let Some(kind) = &self.pending { + return Some(Task::Chat { kind: kind.clone() }); + } + if !background { + return None; + } + let today = &self.state.today; + // Today's page at bedtime, once there is something to write about. + if self.state.asleep + && body::is_night(&self.state, t) + && today.log.log.len() >= 2 + && t - self.last_turn >= 1500.0 + && !self.state.diary.iter().any(|d| d.date == today.date) + { + return Some(Task::Diary { + date: today.date.clone(), + }); + } + if t >= self.next_idle { + return Some(Task::Chat { + kind: "idle".into(), + }); + } + // Missed days, oldest first, once the owner has left the pet alone. + if t - self.state.last_active >= DIARY_QUIET_MS { + if let Some(date) = self.state.history.keys().next() { + return Some(Task::Diary { date: date.clone() }); + } + } + None + } + /// The brain took a task. + pub fn begin_turn(&mut self, t: f64, dice: &mut dyn Dice) { + self.pending = None; + self.last_turn = t; + self.schedule_idle(t, dice); + self.revision += 1; + } + pub fn chat_turn(&self, kind: &str, t: f64, seed: &str) -> ChatTurn { + ChatTurn::new(self.world(t, seed), self.state.chat.clone(), kind) + } + /// Commits a finished line for the current context. Lines without a word + /// add nothing, and an open offer they leave unanswered counts as no. + pub fn commit_line(&mut self, body: &str, t: f64, seed: &str, dice: &mut dyn Dice) { + if body.bytes().any(|b| b.is_ascii_alphanumeric()) { + self.push_line(format!("{}: {body}", self.pet_name())); + self.apply_line(body, t, seed, dice); + } else if self.state.offer.is_some() { + self.answer_offer(false, t, seed, dice); + } + self.revision += 1; + } + /// The diary page for `date` (today or a missed day), unless written. + pub fn diary_turn(&self, date: &str, t: f64, seed: &str, replace: bool) -> Option { + self.state.pet.as_ref()?; + let today = date == self.state.today.date; + if !(replace && today) && self.state.diary.iter().any(|d| d.date == date) { + return None; + } + let mut st = self.world(t, seed); + if !today { + let day = self.state.history.get(date)?; + st.today = day.log.clone(); + st.weather = day.weather.clone().unwrap_or(st.weather); + st.day = day.day.unwrap_or(st.day); + } + Some(DiaryTurn::new(&st, date.into(), replace && today)) + } + pub fn commit_page(&mut self, page: DiaryPage, replace: bool) { + self.state.history.remove(&page.date); + if replace { + self.state.diary.retain(|d| d.date != page.date); + } + if !self.state.diary.iter().any(|d| d.date == page.date) { + self.effect("diary", &page.date); + self.state.diary.push(page); + } + self.revision += 1; + } +} + +fn pick_index(dice: &mut dyn Dice, n: usize) -> usize { + ((dice.roll() * n as f64) as usize).min(n - 1) +} +/// How an offer reads in the day log and in messages ("a cookie", "a bath"). +pub fn offer_thing(o: &Offer) -> String { + match o.kind.as_str() { + "feed" => world::a(o.item.as_deref().unwrap_or("")), + "bath" => "a bath".into(), + "medicine" => "medicine".into(), + _ => "catch".into(), + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..3299b6b --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,12 @@ +//! tokenling's shared rules. The Rust host links this crate natively and the +//! browser runs the same code compiled to WebAssembly (`core/wasm`): the +//! trained prompt contract, the body, care and the brain's tags, turns, the +//! diary and saves. Storage, networking, scheduling threads, rendering and +//! inference stay in each runtime. +pub mod body; +pub mod calendar; +pub mod engine; +pub mod save; +pub mod turn; +pub mod types; +pub mod world; diff --git a/core/src/save.rs b/core/src/save.rs new file mode 100644 index 0000000..443c690 --- /dev/null +++ b/core/src/save.rs @@ -0,0 +1,150 @@ +//! New saves, reading older saves and refusing ones this version cannot read. +//! A save that fails here is never overwritten by the browser (the "bad egg"). +use crate::{ + body::{HOUR, date_key}, + types::*, +}; +use serde_json::{Map, Value, json}; + +/// Save versions. `MIGRATIONS[n]` turns a version n+1 save into version n+2; +/// bump `VERSION` and add one whenever a stored shape changes. +pub const VERSION: u32 = 2; +type Migration = fn(Map, &Hosting) -> Map; +const MIGRATIONS: &[Migration] = &[|mut s, hosting| { + // v2 records where the pet runs; every v1 save ran in its browser. + s.insert("v".into(), json!(2)); + s.insert("hosting".into(), json!(hosting)); + s +}]; + +pub fn fresh(t: f64, time_zone: &str) -> PetState { + let hosting = Hosting { + mode: "local".into(), + brain_id: None, + time_zone: time_zone.into(), + }; + let mut state = PetState { + v: VERSION, + created: t, + owner: String::new(), + pet: None, + egg: None, + stats: Stats { + tummy: 70.0, + fun: 70.0, + energy: 80.0, + clean: 90.0, + }, + sick: false, + poop: 0, + asleep: false, + lights_off: false, + memories: vec![], + shelf: vec![], + chat: vec![], + diary: vec![], + today: Today { + date: String::new(), + log: DayLog::default(), + }, + history: Default::default(), + offer: None, + woke_at: 0.0, + last_tick: t, + last_active: t, + grime_since: 0.0, + next_poop: t + 3.0 * HOUR, + settings: Settings { + chatter: true, + extra: Map::new(), + }, + counts: Counts { + fed: 0, + pats: 0, + games: 0, + baths: 0, + }, + hosting, + extra: Map::new(), + }; + state.today.date = date_key(&state, t); + state +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct SaveError { + pub message: String, + /// The save came from a newer version: keep it for that version. + pub newer: bool, +} +impl SaveError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + newer: false, + } + } +} +impl std::fmt::Display for SaveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for SaveError {} + +/// Reads a stored or exported save, migrating older versions step by step. +/// Missing top-level fields get a new save's defaults, as earlier browsers did. +pub fn parse_save(raw: &str, t: f64, time_zone: &str) -> Result { + let value: Value = serde_json::from_str(raw).map_err(|_| SaveError::new("it is damaged"))?; + let Value::Object(mut s) = value else { + return Err(SaveError::new("it isn't a tokenling save")); + }; + let Some(mut v) = s.get("v").and_then(Value::as_u64).filter(|v| *v >= 1) else { + return Err(SaveError::new("it isn't a tokenling save")); + }; + if v > VERSION as u64 { + return Err(SaveError { + message: format!( + "it was saved by a newer version of tokenling (save version {v}, this page reads {VERSION})" + ), + newer: true, + }); + } + let base = fresh(t, time_zone); + while v < VERSION as u64 { + s = MIGRATIONS[v as usize - 1](s, &base.hosting); + if s.get("v").and_then(Value::as_u64) != Some(v + 1) { + return Err(SaveError::new(format!( + "updating it from version {v} failed" + ))); + } + v += 1; + } + if !s.get("chat").is_some_and(Value::is_array) + || !s.get("diary").is_some_and(Value::is_array) + || !s.get("stats").is_some_and(Value::is_object) + { + return Err(SaveError::new("parts of it are missing")); + } + let hosting_ok = s.get("hosting").is_some_and(|h| { + matches!( + h.get("mode").and_then(Value::as_str), + Some("local" | "remote") + ) && h.get("timeZone").is_some_and(Value::is_string) + && h.get("brainId") + .is_some_and(|b| b.is_null() || b.is_string()) + }); + if !hosting_ok { + return Err(SaveError::new( + "its execution setting is missing or damaged", + )); + } + let Value::Object(mut merged) = serde_json::to_value(&base).unwrap() else { + unreachable!() + }; + merged.extend(s); + let state: PetState = serde_json::from_value(Value::Object(merged)) + .map_err(|e| SaveError::new(format!("parts of it are damaged ({e})")))?; + state.validate().map_err(|e| SaveError::new(e.0))?; + Ok(state) +} diff --git a/core/src/turn.rs b/core/src/turn.rs new file mode 100644 index 0000000..44e1694 --- /dev/null +++ b/core/src/turn.rs @@ -0,0 +1,174 @@ +//! One pet line or diary page: what to ask the brain, how lookups continue +//! the same line, and how the result is filtered. The runtimes only generate. +use crate::{ + types::{DiaryPage, WorldState}, + world, +}; +use serde::{Deserialize, Serialize}; + +/// A request to the brain: continue `header`, the newest `lines` that fit and +/// `prefix`. Prompts end at `Name:` with no trailing space. Sampling keeps the +/// `top_k` likeliest tokens, then the smallest set holding `top_p` of their +/// probability; temperature 0 is greedy (used only by checks). +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[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 top_k: usize, + pub top_p: f32, + pub ctx: usize, +} +pub const TEMPERATURE: f32 = 0.85; +pub const TOP_K: usize = 40; +pub const TOP_P: f32 = 0.92; +/// Lookups per line before the line must end. +const MAX_LOOKUPS: u32 = 3; +/// Filtered lines are generated again this many times in all. +const TRIES: u32 = 3; + +/// The pet's line, with up to three lookup round-trips. Idle turns see a +/// shorter history: prefill is most of their cost. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChatTurn { + pub kind: String, + world: WorldState, + header: String, + lines: Vec, + text: String, + hop: u32, + tries: u32, + body: Option, +} +impl ChatTurn { + pub fn new(world: WorldState, lines: Vec, kind: &str) -> Self { + Self { + kind: if kind == "idle" { "idle" } else { "react" }.into(), + header: world::header("chat", &world), + world, + lines, + text: String::new(), + hop: 0, + tries: 0, + body: None, + } + } + pub fn request(&self) -> Generation { + let idle = self.kind == "idle"; + Generation { + header: self.header.clone(), + lines: self.lines.clone(), + prefix: format!("{}:{}", self.world.pet, self.text), + mode: "line".into(), + max_new: if idle { 40 } else { 64 }, + temperature: TEMPERATURE, + top_k: TOP_K, + top_p: TOP_P, + ctx: if idle { 512 } else { 1024 }, + } + } + /// Adds a generated piece that stopped for `reason`. Returns true when the + /// line needs another generation: after a lookup (its result is appended + /// here) or after a filtered line. + pub fn feed(&mut self, text: &str, reason: &str) -> bool { + if self.body.is_some() { + return false; + } + self.text += text; + if reason == "lookup" && self.hop < MAX_LOOKUPS { + if let Some(result) = world::lookup_tail(&self.text) + .and_then(|(name, arg)| world::tool_result(&self.world, &name, &arg)) + { + self.text += &format!("{{{result}}}"); + self.hop += 1; + return true; + } + } + if world::not_cute(&self.text) { + self.tries += 1; + self.text.clear(); + self.hop = 0; + if self.tries < TRIES { + return true; + } + self.body = Some(String::new()); + return false; + } + self.body = Some(world::compact(&self.text)); + false + } + /// The line so far, with lookup results, for live display. + pub fn text(&self) -> &str { + &self.text + } + /// The finished line, empty when every try was filtered. + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } +} + +/// A diary page for one day, written from that day's log. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DiaryTurn { + pub date: String, + pub day: i64, + pub weather: String, + pub stage: String, + /// Debug rewrites replace today's page. + pub replace: bool, + header: String, +} +pub const DIARY_START: &str = "dear diary,"; +impl DiaryTurn { + pub fn new(world: &WorldState, date: String, replace: bool) -> Self { + Self { + date, + day: world.day, + weather: world.weather.clone(), + stage: world.stage.clone(), + replace, + header: world::header("diary", world), + } + } + pub fn request(&self) -> Generation { + Generation { + header: self.header.clone(), + lines: vec![], + prefix: DIARY_START.into(), + mode: "diary".into(), + max_new: 220, + temperature: TEMPERATURE, + top_k: TOP_K, + top_p: TOP_P, + ctx: 1024, + } + } + /// The page from the brain's continuation: up to three known doodle + /// stickers, text without tags. A page with unsafe text is dropped whole + /// rather than publishing its fragments; the day stays pending. + pub fn finish(&self, generated: &str) -> Option { + let text = format!("{DIARY_START}{generated}"); + if world::not_cute(&text) { + return None; + } + let doodles = world::tags(&text) + .into_iter() + .filter(|t| t.name == "doodle") + .filter_map(|t| t.arg) + .filter(|d| world::DOODLES.contains(&d.as_str())) + .take(3) + .collect(); + Some(DiaryPage { + date: self.date.clone(), + day: self.day, + weather: self.weather.clone(), + stage: self.stage.clone(), + text: world::clean_page(&text), + doodles, + }) + } +} diff --git a/server/src/types.rs b/core/src/types.rs similarity index 59% rename from server/src/types.rs rename to core/src/types.rs index 5cddc46..2711e9c 100644 --- a/server/src/types.rs +++ b/core/src/types.rs @@ -1,3 +1,5 @@ +//! The save (version 2) and the prompt's world state. Field names match the +//! browser's JSON; unknown fields are kept so newer data survives a round trip. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::BTreeMap; @@ -10,7 +12,7 @@ pub struct Hosting { pub time_zone: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Stats { pub tummy: f64, pub fun: f64, @@ -33,7 +35,7 @@ pub struct Egg { pub laid: f64, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct DayLog { pub log: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -65,6 +67,7 @@ pub struct Offer { pub item: Option, } +/// `chatter` belongs to the pet; the other keys are device preferences. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Settings { pub chatter: bool, @@ -112,28 +115,52 @@ pub struct PetState { pub extra: Map, } +pub const NAME_FORBIDDEN: [char; 7] = ['\n', ':', '[', ']', '{', '}', '*']; +pub const MAX_CHAT: usize = 240; +pub const MAX_MEMORIES: usize = 60; +pub const MAX_SHELF: usize = 24; + +/// Why a save cannot be used, worded to follow "This save cannot be read: ". +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidSave(pub String); +impl std::fmt::Display for InvalidSave { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for InvalidSave {} + impl PetState { - pub fn validate(&self) -> anyhow::Result<()> { - anyhow::ensure!(self.v == 2, "this host reads save version 2"); - anyhow::ensure!( + pub fn validate(&self) -> Result<(), InvalidSave> { + fn ensure(ok: bool, why: &str) -> Result<(), InvalidSave> { + if ok { + Ok(()) + } else { + Err(InvalidSave(why.into())) + } + } + ensure(self.v == 2, "this version reads save version 2")?; + 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" - ); + "its execution setting is damaged", + )?; + ensure( + crate::calendar::valid_zone(&self.hosting.time_zone), + "its time zone is unknown", + )?; + ensure( + self.owner.chars().count() <= 24 && !self.owner.contains(NAME_FORBIDDEN), + "its owner name is invalid", + )?; 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"); + ensure((0.0..=100.0).contains(&v), "a need level is out of range")?; } - anyhow::ensure!(self.poop <= 4, "invalid poop count"); + ensure(self.poop <= 4, "its poop count is invalid")?; for t in [ self.created, self.last_tick, @@ -142,7 +169,7 @@ impl PetState { self.woke_at, self.grime_since, ] { - anyhow::ensure!((0.0..=8.64e15).contains(&t), "invalid save timestamp"); + ensure((0.0..=8.64e15).contains(&t), "a timestamp is invalid")?; } for count in [ self.counts.fed, @@ -150,38 +177,47 @@ impl PetState { self.counts.games, self.counts.baths, ] { - anyhow::ensure!(count <= 1_000_000_000_000, "invalid care count"); + ensure(count <= 1_000_000_000_000, "a care count is invalid")?; } - anyhow::ensure!( - self.chat.len() <= 240 && self.memories.len() <= 60 && self.shelf.len() <= 24, - "save exceeds history limits" - ); + ensure( + self.chat.len() <= MAX_CHAT + && self.memories.len() <= MAX_MEMORIES + && self.shelf.len() <= MAX_SHELF, + "it exceeds the history limits", + )?; if let Some(pet) = &self.pet { - anyhow::ensure!( + ensure( !pet.name.is_empty() && pet.name.chars().count() <= 16 - && !pet.name.contains(['\n', ':', '[', ']', '{', '}', '*']), - "invalid pet name" - ); - anyhow::ensure!( + && !pet.name.contains(NAME_FORBIDDEN), + "its pet name is invalid", + )?; + ensure( crate::world::STAGES.contains(&pet.stage.as_str()), - "invalid pet stage" - ); - anyhow::ensure!( + "its pet stage is invalid", + )?; + ensure( pet.traits .iter() .all(|t| crate::world::PERSONALITIES.contains(&t.as_str())), - "invalid personality" - ); - anyhow::ensure!( + "its personality is invalid", + )?; + ensure( crate::world::TOYS.contains(&pet.toy.as_str()), - "invalid toy" - ); + "its toy is invalid", + )?; + } + if let Some(offer) = &self.offer { + ensure( + matches!(offer.kind.as_str(), "feed" | "bath" | "medicine" | "play"), + "its open offer is invalid", + )?; } Ok(()) } } +/// Everything the prompt header and lookups read, at one moment. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct WorldState { pub pet: String, diff --git a/core/src/world.rs b/core/src/world.rs new file mode 100644 index 0000000..72ac13a --- /dev/null +++ b/core/src/world.rs @@ -0,0 +1,442 @@ +//! The trained prompt contract: closed word lists, headers, lookups, event +//! lines, the tag grammar and the generation stop rules. `train/world.py` +//! renders the same strings for training; `node test/parity.mjs` and +//! `node server/tests/parity.mjs` check both builds of this module against it. +use crate::types::{Stats, WorldState}; +use regex_lite::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"]; +/// Body colors are the pet's look, not part of the prompt. +pub const COLORS: &[&str] = &["cream", "pink", "mint", "butter", "lavender", "sky"]; +pub const EFFECT_TOOLS: &[&str] = &[ + "emote", "want", "remember", "gift", "sleep", "dream", "diary", "walk", "yes", "no", +]; +/// `[yes]`/`[no]` answer an offer event line and may appear nowhere else. +pub const ANSWER_TOOLS: &[&str] = &["yes", "no"]; +pub const LOOKUP_TOOLS: &[&str] = &["clock", "recall", "look", "yesterday"]; + +/// One tag: `[name]`, `[name:arg]`, optionally followed by a lookup `{result}`. +pub static TAG: LazyLock = LazyLock::new(|| { + Regex::new(r"\[([A-Za-z0-9_]+)(?::([^\]\n]{1,80}))?\](\{[^}\n]*\})?").unwrap() +}); +/// GPT-2 learned from the open web; keep the pet's mouth clean regardless. +/// ASCII word boundaries and case folding, as the browser's regex had. +pub static NOT_CUTE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\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() +}); +// GPT-2 has a single "][" token, so a lookup can close mid-token. +static LOOKUP_END: LazyLock = LazyLock::new(|| { + Regex::new(r"\[(clock|look|yesterday|recall)(?::([^\]\n]{1,80}))?\]\[?$").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"]), + ] +} +fn tail(items: &[T], n: usize) -> &[T] { + &items[items.len().saturating_sub(n)..] +} +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: {}", tail(&st.memories, 3).join("; "))); + } + let said = format!("{} said: ", st.owner); + let today: Vec<_> = st + .today + .iter() + .filter(|t| kind != "chat" || !t.starts_with(&said)) + .cloned() + .collect(); + if !today.is_empty() { + lines.push(format!("today: {}", tail(&today, 6).join("; "))); + } + lines.push("---".into()); + lines.join("\n") + "\n" +} + +pub fn words(text: &str) -> HashSet { + const STOP: &[&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", + ]; + 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() +} +/// Deterministic word-overlap search, exactly as the training data's results. +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 clock(st: &WorldState) -> String { + format!("{:02}:{:02} {}", st.hh, st.mm, part_of_day(st.hh)) +} +pub fn look(st: &WorldState) -> String { + let shelf = if st.shelf.is_empty() { + "empty".into() + } else { + tail(&st.shelf, 4).join(", ") + }; + let mut out = format!("shelf: {shelf} · toy: {} · window: {}", st.toy, st.weather); + if st.poop > 0 { + out += &format!(" · poop x{}", st.poop); + } + out +} +/// The `{result}` the app appends after a lookup tag. The model never writes it. +pub fn tool_result(st: &WorldState, name: &str, arg: &str) -> Option { + match name { + "clock" => Some(clock(st)), + "look" => Some(look(st)), + "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), + } +} +/// Chance that a disturbance wakes a sleeping pet: talking, touching or the +/// lights, the pet's energy and how many disturbances came within 5 minutes. +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) +} + +/// App event lines, exactly as `train/world.py` `event_patterns()` accepts them. +/// Offers ask the pet to decide; its next line answers with `[yes]` or `[no]`. +pub mod events { + use super::a; + pub fn offer_food(o: &str, p: &str, food: &str) -> String { + format!("* {o} offered {p} {} *", a(food)) + } + pub fn offer_bath(o: &str, p: &str) -> String { + format!("* {o} offered {p} a bath *") + } + pub fn offer_medicine(o: &str, p: &str) -> String { + format!("* {o} offered {p} medicine *") + } + pub fn offer_catch(o: &str, p: &str) -> String { + format!("* {o} asked {p} to play catch *") + } + pub fn caught(o: &str, p: &str, n: u32) -> String { + format!("* {o} played catch with {p} ({n} {}) *", stars(n)) + } + pub fn pat(o: &str, p: &str) -> String { + format!("* {o} patted {p} *") + } + pub fn lights(o: &str, on: bool) -> String { + format!( + "* {o} turned {} the lights *", + if on { "on" } else { "off" } + ) + } + pub fn woke(p: &str) -> String { + format!("* {p} woke up *") + } + pub fn pooped(p: &str) -> String { + format!("* {p} pooped *") + } + pub fn cleaned(o: &str) -> String { + format!("* {o} cleaned up the poop *") + } + pub fn back(o: &str, span: &str) -> String { + format!("* {o} came back after {span} *") + } + pub fn grew(p: &str, stage: &str) -> String { + format!("* {p} grew into a {stage}! *") + } + pub fn stars(n: u32) -> &'static str { + if n == 1 { "star" } else { "stars" } + } +} + +/// A tag the brain wrote; `result` is a lookup result the app appended. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Tag { + pub name: String, + pub arg: Option, + pub result: Option, +} +pub fn tags(text: &str) -> Vec { + TAG.captures_iter(text) + .map(|c| Tag { + name: c[1].to_owned(), + arg: c.get(2).map(|m| m.as_str().to_owned()), + result: c.get(3).map(|m| m.as_str()[1..m.len() - 1].to_owned()), + }) + .collect() +} +/// Text with tags, lookup results and stray brackets removed. +pub fn clean_text(text: &str) -> String { + SPACES + .replace_all( + &TAG.replace_all(text, " ").replace(['[', ']', '{', '}'], ""), + " ", + ) + .trim() + .to_owned() +} +/// Diary text: tags and stray brackets removed, paragraphs kept. +pub fn clean_page(text: &str) -> String { + let stripped = TAG.replace_all(text, " ").replace(['[', ']', '{', '}'], ""); + stripped + .lines() + .map(compact) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n") +} +/// A line made only of tags: the pet acting on its own. +pub fn silent(text: &str) -> bool { + !clean_text(text).bytes().any(|b| b.is_ascii_alphabetic()) +} +pub fn not_cute(text: &str) -> bool { + NOT_CUTE.is_match(text) +} +pub fn compact(text: &str) -> String { + SPACES.replace_all(text, " ").trim().to_owned() +} +/// Owner-supplied names: no characters that could forge prompt structure. +pub fn clean_name(name: &str, max: usize) -> String { + name.trim() + .chars() + .filter(|c| ![':', '*', '[', ']', '{', '}', '\n'].contains(c)) + .take(max) + .collect() +} +/// Owner messages: one line without tag or event punctuation, at most 200 characters. +pub fn clean_message(text: &str) -> String { + compact(&text.replace(['\n', '[', ']', '{', '}', '*'], " ")) + .chars() + .take(200) + .collect() +} + +/// A complete lookup tag at the end of generated text, ready for its result. +pub fn lookup_tail(text: &str) -> Option<(String, String)> { + let c = LOOKUP_END.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; + } + Some((name, arg)) +} +/// Where generation stops: a chat line at its newline or right after a +/// complete lookup tag (so the app can append `{result}`); a diary at a new +/// header or a blank gap. Returns the reason and the text to keep. +pub fn stop(mode: &str, text: &str) -> Option<(&'static str, String)> { + if mode == "line" { + if let Some(at) = text.find('\n') { + return Some(("line", text[..at].to_owned())); + } + if lookup_tail(text).is_some() { + let kept = text + .strip_suffix("][") + .map_or_else(|| text.to_owned(), |s| s.to_owned() + "]"); + return Some(("lookup", kept)); + } + } else if let Some(at) = text.find("\n##").or_else(|| text.find("\n\n\n")) { + return Some(("end", text[..at].to_owned())); + } + None +} +/// Drops the oldest chat lines until header, lines and prefix fit in `ctx` +/// tokens with room for `max_new`. `count` is the brain's tokenizer. +pub fn fit( + header: &str, + lines: &[String], + prefix: &str, + max_new: usize, + ctx: usize, + count: &mut dyn FnMut(&str) -> Result, +) -> Result { + let budget = ctx + .min(1024) + .checked_sub(max_new + 4) + .ok_or("generation budget exceeds context")?; + let mut used = count(header)? + count(prefix)?; + if used > budget { + return Err("prompt header does not fit the selected brain context".into()); + } + let mut start = lines.len(); + while start > 0 { + let size = count(&(lines[start - 1].clone() + "\n"))?; + if used + size > budget { + break; + } + used += size; + start -= 1; + } + Ok(header.to_owned() + + &lines[start..] + .iter() + .map(|l| l.clone() + "\n") + .collect::() + + prefix) +} diff --git a/core/tests/rules.rs b/core/tests/rules.rs new file mode 100644 index 0000000..f5215e6 --- /dev/null +++ b/core/tests/rules.rs @@ -0,0 +1,222 @@ +//! Native checks of the shared rules. The browser build runs the same code; +//! `server/tests/parity.mjs` compares the two builds step by step. +use tokenling_core::{ + body::{DAY, HOUR, MIN}, + engine::{Command, Engine, Task}, + save, world, +}; + +const FIXTURE: &str = include_str!("../../test/fixtures/save-v1.json"); +// 2026-10-01 12:00 UTC. +const NOON: f64 = 1_790_856_000_000.0; +fn engine() -> Engine { + let mut state = save::parse_save(FIXTURE, NOON, "UTC").unwrap(); + state.last_tick = NOON; + state.next_poop = NOON + 10.0 * HOUR; + state.pet.as_mut().unwrap().hatched = NOON; + Engine::new(state, NOON) +} +fn dice() -> impl FnMut() -> f64 { + || 0.5 +} +fn care(kind: &str, item: Option<&str>) -> Command { + Command::Care { + kind: kind.into(), + item: item.map(Into::into), + } +} + +#[test] +fn older_saves_migrate_and_unreadable_ones_are_refused() { + let state = save::parse_save(FIXTURE, NOON, "Europe/Berlin").unwrap(); + assert_eq!(state.v, save::VERSION); + assert_eq!(state.hosting.mode, "local"); + assert_eq!(state.hosting.time_zone, "Europe/Berlin"); + assert_eq!(state.pet.unwrap().name, "Mochi"); + let newer = save::parse_save(r#"{"v":99}"#, NOON, "UTC").unwrap_err(); + assert!(newer.newer); + assert_eq!( + save::parse_save("{", NOON, "UTC").unwrap_err().message, + "it is damaged" + ); + assert!( + save::parse_save(r#"{"v":1}"#, NOON, "UTC") + .unwrap_err() + .message + .contains("missing") + ); +} + +#[test] +fn stop_rules_and_context_fit_are_shared() { + assert_eq!( + world::stop("line", " hi\nSam:"), + Some(("line", " hi".into())) + ); + assert_eq!( + world::stop("line", " it is [clock]["), + Some(("lookup", " it is [clock]".into())) + ); + assert_eq!(world::stop("line", " [recall]"), None); + assert_eq!(world::stop("line", " [look:x]"), None); + assert_eq!( + world::stop("diary", " a day.\n## tokenling"), + Some(("end", " a day.".into())) + ); + let lines: Vec = (0..10).map(|i| format!("line {i}")).collect(); + // One "token" per character: header 10 + prefix 6, budget 64 - 20 - 4 = 40. + let prompt = world::fit("## header\n", &lines, "Mochi:", 20, 64, &mut |t| { + Ok(t.chars().count()) + }) + .unwrap(); + assert_eq!(prompt, "## header\nline 7\nline 8\nline 9\nMochi:"); + assert!( + world::fit("x".repeat(50).as_str(), &lines, "", 20, 64, &mut |t| Ok( + t.len() + )) + .is_err() + ); +} + +#[test] +fn a_line_continues_after_lookups_and_unsafe_lines_are_retried() { + let e = engine(); + let mut turn = e.chat_turn("react", NOON, ""); + assert_eq!(turn.request().prefix, "Mochi:"); + assert!(turn.feed(" hmm [recall:frogs]", "lookup")); + assert!(turn.request().prefix.ends_with("[recall:frogs]{nothing}")); + assert!( + turn.feed(" shit", "line"), + "a filtered line is generated again" + ); + assert_eq!(turn.request().prefix, "Mochi:"); + assert!(!turn.feed(" ok then ", "line")); + assert_eq!(turn.body(), Some("ok then")); + let mut idle = e.chat_turn("idle", NOON, ""); + assert_eq!((idle.request().max_new, idle.request().ctx), (40, 512)); + for _ in 0..3 { + idle.feed(" fuck", "line"); + } + assert_eq!(idle.body(), Some("")); +} + +#[test] +fn offers_wait_for_the_brain_and_answers_apply_once() { + let mut e = engine(); + let mut d = dice(); + e.command(care("feed", Some("cookie")), NOON, "", &mut d) + .unwrap(); + assert_eq!(e.pending.as_deref(), Some("react")); + let refused = e.command(care("bath", None), NOON, "", &mut d).unwrap_err(); + assert_eq!(refused.0, "Mochi is still deciding about a cookie."); + let tummy = e.state.stats.tummy; + e.commit_line("yum [yes]", NOON, "", &mut d); + assert!(e.state.offer.is_none()); + assert_eq!(e.state.stats.tummy, (tummy + 22.0).min(100.0)); + assert!( + e.effects + .iter() + .any(|x| x.kind == "eat" && x.arg == "cookie") + ); + e.command(care("medicine", None), NOON, "", &mut d).unwrap(); + e.commit_line("...", NOON, "", &mut d); + assert!(e.state.offer.is_none(), "a wordless line counts as no"); + assert_eq!(e.state.today.log.log.last().unwrap(), "said no to medicine"); + assert_eq!( + e.command(care("clean", None), NOON, "", &mut d) + .unwrap_err() + .0, + "All clean already." + ); +} + +#[test] +fn turns_follow_reactions_then_bedtime_then_idle_then_missed_pages() { + let mut e = engine(); + let mut d = dice(); + e.command( + Command::Message { + text: "hi [x]\n*there*".into(), + }, + NOON, + "", + &mut d, + ) + .unwrap(); + assert_eq!(e.state.chat.last().unwrap(), "Sam: hi x there"); + assert_eq!( + e.choose_task(NOON, false), + Some(Task::Chat { + kind: "react".into() + }) + ); + e.begin_turn(NOON, &mut d); + assert_eq!(e.choose_task(NOON, false), None); + assert_eq!( + e.choose_task(e.next_idle, true), + Some(Task::Chat { + kind: "idle".into() + }) + ); + // Night, asleep, with a day worth writing about: today's page first. + let night = NOON + 10.0 * HOUR; + e.state.asleep = true; + e.state.today.log.log = vec!["ate a cookie".into(), "got patted".into()]; + let today = e.state.today.date.clone(); + assert_eq!( + e.choose_task(night, true), + Some(Task::Diary { + date: today.clone() + }) + ); + let turn = e.diary_turn(&today, night, "", false).unwrap(); + assert_eq!(turn.request().prefix, "dear diary,"); + assert!(turn.finish(" we had sex").is_none()); + let page = turn + .finish(" fun [doodle:heart] [doodle:robot]\n\nthe end [emote:happy]") + .unwrap(); + assert_eq!(page.text, "dear diary, fun\nthe end"); + assert_eq!(page.doodles, vec!["heart"]); + e.commit_page(page, false); + assert!(e.diary_turn(&today, night, "", false).is_none()); + // A day without a page waits in history and is written after 30 quiet seconds. + e.state.asleep = false; + let tomorrow = NOON + DAY; + e.state.last_active = tomorrow; + e.log("ate an apple".into(), tomorrow, ""); + e.log("napped".into(), tomorrow, ""); + let next = NOON + 2.0 * DAY; + e.roll_day(next, ""); + let missed = e.state.history.keys().next().unwrap().clone(); + e.next_idle = next + HOUR; + assert_eq!( + e.choose_task(next + 31_000.0, true), + Some(Task::Diary { date: missed }) + ); +} + +#[test] +fn absences_catch_up_quietly_and_are_noticed() { + let mut e = engine(); + let mut d = dice(); + e.advance(NOON + 3.0 * HOUR, "", true, &mut d); + assert_eq!( + e.state.chat.last().unwrap(), + "* Sam came back after 3 hours *" + ); + assert_eq!(e.state.pet.as_ref().unwrap().stage, "baby"); + e.advance(NOON + 13.0 * HOUR, "", false, &mut d); + assert_eq!(e.state.pet.as_ref().unwrap().stage, "kid"); + assert!(e.effects.iter().any(|x| x.kind == "grew")); + // A week caps the simulation; ten days away do not overflow anything. + e.advance(NOON + 10.0 * DAY + MIN, "", true, &mut d); + assert_eq!(e.state.last_tick, NOON + 10.0 * DAY + MIN); + assert!( + e.state + .chat + .last() + .unwrap() + .starts_with("* Sam came back after") + || e.state.chat.last().unwrap().ends_with("woke up *") + ); +} diff --git a/test/wasm-core-proof/Cargo.lock b/core/wasm/Cargo.lock similarity index 87% rename from test/wasm-core-proof/Cargo.lock rename to core/wasm/Cargo.lock index 8d06abc..823cb1a 100644 --- a/test/wasm-core-proof/Cargo.lock +++ b/core/wasm/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - [[package]] name = "autocfg" version = "1.5.1" @@ -159,33 +144,10 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.13.1" +name = "regex-lite" +version = "0.1.9" 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" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "rustversion" @@ -271,15 +233,26 @@ dependencies = [ ] [[package]] -name = "tokenling-wasm-proof" +name = "tokenling-core" version = "0.1.0" dependencies = [ - "anyhow", + "chrono", "chrono-tz", - "regex", + "js-sys", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "tokenling-core-wasm" +version = "0.1.0" +dependencies = [ + "js-sys", "serde", "serde-wasm-bindgen", "serde_json", + "tokenling-core", "wasm-bindgen", ] diff --git a/test/wasm-core-proof/Cargo.toml b/core/wasm/Cargo.toml similarity index 55% rename from test/wasm-core-proof/Cargo.toml rename to core/wasm/Cargo.toml index 9b2a195..35b07dd 100644 --- a/test/wasm-core-proof/Cargo.toml +++ b/core/wasm/Cargo.toml @@ -1,24 +1,27 @@ [package] -name = "tokenling-wasm-proof" +name = "tokenling-core-wasm" version = "0.1.0" edition = "2024" rust-version = "1.88" +license = "MIT" publish = false +description = "WebAssembly bindings of tokenling-core for the browser app" [lib] crate-type = ["cdylib"] +name = "tokenling_core" [dependencies] -anyhow = "1" -chrono-tz = "0.10.4" -regex = "1" +js-sys = "=0.3.106" serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" serde_json = "1" -serde-wasm-bindgen = "0.6" +tokenling-core = { path = ".." } wasm-bindgen = "=0.2.129" [profile.release] opt-level = "s" lto = true codegen-units = 1 +panic = "abort" strip = "debuginfo" diff --git a/core/wasm/build.sh b/core/wasm/build.sh new file mode 100755 index 0000000..cdd6142 --- /dev/null +++ b/core/wasm/build.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Build the shared rules (core/) for the browser: a release WebAssembly module +# plus its wasm-bindgen ES module glue and TypeScript declarations. +# +# Usage: core/wasm/build.sh [OUT_DIR] (default: core/wasm/pkg) +# +# Requires Rust 1.88+ with the wasm32-unknown-unknown target and +# wasm-bindgen-cli at exactly the version Cargo.lock pins for wasm-bindgen. +# Reads no other environment. OUT_DIR is replaced only after both steps pass. +set -euo pipefail +[ $# -le 1 ] || { sed -n '2,/^set -e/p' "$0" | sed '$d;s/^# \{0,1\}//' >&2; exit 2; } +HERE=$(cd "$(dirname "$0")" && pwd) +OUT=$(realpath -m "${1:-$HERE/pkg}") +command -v cargo >/dev/null || { echo "core/wasm/build.sh: cargo is required (Rust 1.88+)" >&2; exit 2; } +command -v wasm-bindgen >/dev/null || { + echo "core/wasm/build.sh: wasm-bindgen-cli is required: cargo install wasm-bindgen-cli --version VERSION --locked" >&2 + exit 2 +} +want=$(sed -n '/^name = "wasm-bindgen"$/{n;s/^version = "\(.*\)"$/\1/p;}' "$HERE/Cargo.lock") +have=$(wasm-bindgen --version | cut -d' ' -f2) +[ "$want" = "$have" ] || { + echo "core/wasm/build.sh: wasm-bindgen-cli $have does not match the locked crate $want" >&2 + exit 2 +} +rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown || [ ! -x "$(command -v rustup)" ] || { + echo "core/wasm/build.sh: run: rustup target add wasm32-unknown-unknown" >&2 + exit 2 +} +cargo build --release --locked --quiet --manifest-path "$HERE/Cargo.toml" --target wasm32-unknown-unknown +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +wasm-bindgen "$HERE/target/wasm32-unknown-unknown/release/tokenling_core.wasm" \ + --target web --out-dir "$STAGE" +rm -rf "$OUT" +mkdir -p "$(dirname "$OUT")" +mv "$STAGE" "$OUT" +chmod 755 "$OUT" +trap - EXIT diff --git a/core/wasm/src/lib.rs b/core/wasm/src/lib.rs new file mode 100644 index 0000000..3502404 --- /dev/null +++ b/core/wasm/src/lib.rs @@ -0,0 +1,504 @@ +//! JavaScript bindings of tokenling-core for the browser page and its brain +//! worker. `web/core.ts` wraps these exports with types. No storage, network +//! or model access happens here; the page passes in times and saves. +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::json; +use tokenling_core::{ + body, + engine::{self, Command, Dice}, + save, turn, + types::{PetState, Stats, WorldState}, + world, +}; +use wasm_bindgen::prelude::*; + +fn to_js(value: &T) -> Result { + Ok(value.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) +} +fn from_js(value: JsValue) -> Result { + Ok(serde_wasm_bindgen::from_value(value)?) +} +fn sorted(values: &[&str]) -> Vec { + let mut values: Vec<_> = values.iter().map(|v| v.to_string()).collect(); + values.sort(); + values +} + +/// `Math.random`, or test overrides for every roll or only wake rolls. +struct JsDice<'a> { + rolls: Option<&'a js_sys::Function>, + wake: Option<&'a js_sys::Function>, +} +fn call(f: Option<&js_sys::Function>) -> Option { + f?.call0(&JsValue::NULL).ok()?.as_f64() +} +impl Dice for JsDice<'_> { + fn roll(&mut self) -> f64 { + call(self.rolls).unwrap_or_else(js_sys::Math::random) + } + fn wake(&mut self) -> f64 { + call(self.wake).unwrap_or_else(|| self.roll()) + } +} + +/// The closed word lists, as `train/world.py` names them. +#[wasm_bindgen] +pub fn lists() -> Result { + to_js(&json!({ + "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, "COLORS": world::COLORS, + "EFFECT_TOOLS": sorted(world::EFFECT_TOOLS), "LOOKUP_TOOLS": sorted(world::LOOKUP_TOOLS), + "ANSWER_TOOLS": sorted(world::ANSWER_TOOLS), + })) +} +#[wasm_bindgen] +pub fn header(kind: &str, world_state: JsValue) -> Result { + if !["chat", "diary"].contains(&kind) { + return Err(JsError::new("unknown prompt kind")); + } + Ok(world::header(kind, &from_js::(world_state)?)) +} +#[wasm_bindgen(js_name = toolResult)] +pub fn tool_result(world_state: JsValue, name: &str, arg: &str) -> Result, JsError> { + Ok(world::tool_result(&from_js(world_state)?, name, arg)) +} +#[wasm_bindgen] +pub fn recall(memories: Vec, query: &str) -> String { + world::recall(&memories, query) +} +#[wasm_bindgen(js_name = partOfDay)] +pub fn part_of_day(hour: u32) -> String { + world::part_of_day(hour).into() +} +#[wasm_bindgen(js_name = levelWords)] +pub fn level_words(stats: JsValue) -> Result { + let [tummy, fun, energy, clean] = world::level_words(&from_js::(stats)?); + to_js(&json!({"tummy": tummy, "fun": fun, "energy": energy, "clean": clean})) +} +#[wasm_bindgen(js_name = wakeChance)] +pub fn wake_chance(energy: f64, kind: &str, n: u32) -> f64 { + world::wake_chance(energy, kind, n) +} +#[wasm_bindgen(js_name = dreamThing)] +pub fn dream_thing(item: &str) -> String { + world::dream_thing(item) +} +/// An app event line by name (`offerFood`, `pat`, `woke`...), for parity checks. +#[wasm_bindgen] +pub fn event(name: &str, owner: &str, pet: &str, arg: &str) -> Result { + use world::events as e; + Ok(match name { + "offerFood" => e::offer_food(owner, pet, arg), + "offerBath" => e::offer_bath(owner, pet), + "offerMedicine" => e::offer_medicine(owner, pet), + "offerCatch" => e::offer_catch(owner, pet), + "caught" => e::caught( + owner, + pet, + arg.parse().map_err(|_| JsError::new("bad count"))?, + ), + "pat" => e::pat(owner, pet), + "lights" => e::lights(owner, arg == "on"), + "woke" => e::woke(pet), + "pooped" => e::pooped(pet), + "cleaned" => e::cleaned(owner), + "back" => e::back(owner, arg), + "grew" => e::grew(pet, arg), + _ => return Err(JsError::new("unknown event")), + }) +} +#[wasm_bindgen] +pub fn tags(text: &str) -> Result { + to_js(&world::tags(text)) +} +#[wasm_bindgen(js_name = cleanText)] +pub fn clean_text(text: &str) -> String { + world::clean_text(text) +} +#[wasm_bindgen] +pub fn silent(text: &str) -> bool { + world::silent(text) +} +#[wasm_bindgen(js_name = notCute)] +pub fn not_cute(text: &str) -> bool { + world::not_cute(text) +} +#[wasm_bindgen(js_name = cleanName)] +pub fn clean_name(name: &str, max: usize) -> String { + world::clean_name(name, max) +} +#[wasm_bindgen(js_name = cleanMessage)] +pub fn clean_message(text: &str) -> String { + world::clean_message(text) +} +/// The stop rule for generated text: `{reason, text}` or undefined. +#[wasm_bindgen] +pub fn stop(mode: &str, text: &str) -> Result { + match world::stop(mode, text) { + Some((reason, text)) => to_js(&json!({"reason": reason, "text": text})), + None => Ok(JsValue::UNDEFINED), + } +} +/// Fits a prompt into the context; `count` is the tokenizer's token count. +#[wasm_bindgen] +pub fn fit( + header: &str, + lines: Vec, + prefix: &str, + max_new: usize, + ctx: usize, + count: &js_sys::Function, +) -> Result { + let mut count = |text: &str| -> Result { + count + .call1(&JsValue::NULL, &text.into()) + .map_err(|e| format!("{e:?}"))? + .as_f64() + .map(|n| n as usize) + .ok_or_else(|| "the token count is not a number".into()) + }; + world::fit(header, &lines, prefix, max_new, ctx, &mut count).map_err(|e| JsError::new(&e)) +} +#[wasm_bindgen(js_name = offerThing)] +pub fn offer_thing(offer: JsValue) -> Result { + Ok(engine::offer_thing(&from_js(offer)?)) +} +/// Growth stages with the age each starts at, oldest first. +#[wasm_bindgen(js_name = stageAges)] +pub fn stage_ages() -> Result { + to_js(&body::STAGE_AGES) +} +#[wasm_bindgen(js_name = stageFor)] +pub fn stage_for(age: f64) -> String { + body::stage_for(age).into() +} +#[wasm_bindgen(js_name = freshSave)] +pub fn fresh_save(now: f64, time_zone: &str) -> Result { + to_js(&save::fresh(now, time_zone)) +} +/// Reads a save: `{state}`, or `{error, newer}` for a save this page must keep. +#[wasm_bindgen(js_name = parseSave)] +pub fn parse_save(raw: &str, now: f64, time_zone: &str) -> Result { + match save::parse_save(raw, now, time_zone) { + Ok(state) => to_js(&json!({ "state": state })), + Err(e) => to_js(&json!({ "error": e.message, "newer": e.newer })), + } +} +#[wasm_bindgen(js_name = saveVersion)] +pub fn save_version() -> u32 { + save::VERSION +} +/// Runs the body on a copy of `state`; returns `{state, events}`. +#[wasm_bindgen(js_name = simulateBody)] +pub fn simulate_body(state: JsValue, from: f64, to: f64, quiet: bool) -> Result { + let mut state: PetState = from_js(state)?; + let events = body::simulate_body(&mut state, from, to, quiet, &mut js_sys::Math::random); + to_js(&json!({ "state": state, "events": events })) +} + +/// A running pet. The browser keeps one for the save in this tab. +#[wasm_bindgen] +pub struct Engine { + inner: engine::Engine, + seed: String, + overrides: Overrides, + seen: u64, +} +#[derive(Default)] +struct Overrides { + rolls: Option, + wake: Option, +} +impl Overrides { + fn dice(&self) -> JsDice<'_> { + JsDice { + rolls: self.rolls.as_ref(), + wake: self.wake.as_ref(), + } + } +} +#[wasm_bindgen] +impl Engine { + #[wasm_bindgen(constructor)] + pub fn new(state: JsValue, now: f64, seed: String) -> Result { + let state: PetState = from_js(state)?; + state.validate().map_err(|e| JsError::new(&e.0))?; + Ok(Self { + inner: engine::Engine::new(state, now), + seed, + overrides: Overrides::default(), + seen: 0, + }) + } + /// Replaces the save (an import, a transfer or a server snapshot). Turn + /// requests, disturbances and an accepted game belong to the old copy. + pub fn replace(&mut self, state: JsValue, now: f64) -> Result<(), JsError> { + let state: PetState = from_js(state)?; + state.validate().map_err(|e| JsError::new(&e.0))?; + let seq = self.inner.effect_seq; + let revision = self.inner.revision + 1; + let context = self.inner.context_revision + 1; + self.inner = engine::Engine::new(state, now); + (self.inner.effect_seq, self.seen) = (seq, seq); + self.inner.revision = revision; + self.inner.context_revision = context; + Ok(()) + } + /// Replaces the save but keeps the turn queue and effects (debug edits). + pub fn edit(&mut self, state: JsValue) -> Result<(), JsError> { + let state: PetState = from_js(state)?; + state.validate().map_err(|e| JsError::new(&e.0))?; + self.inner.state = state; + self.inner.revision += 1; + self.inner.context_revision += 1; + Ok(()) + } + pub fn state(&self) -> Result { + to_js(&self.inner.state) + } + #[wasm_bindgen(js_name = stateJson)] + pub fn state_json(&self) -> Result { + Ok(serde_json::to_string(&self.inner.state)?) + } + /// The fields a body tick changes, for cheap refreshes between full reads. + pub fn body(&self) -> Result { + let s = &self.inner.state; + to_js(&json!({ + "stats": s.stats, "sick": s.sick, "poop": s.poop, "asleep": s.asleep, + "lightsOff": s.lights_off, "grimeSince": s.grime_since, "nextPoop": s.next_poop, + "lastTick": s.last_tick, "lastActive": s.last_active, "wokeAt": s.woke_at, + })) + } + #[wasm_bindgen(getter)] + pub fn revision(&self) -> f64 { + self.inner.revision as f64 + } + #[wasm_bindgen(getter, js_name = contextRevision)] + pub fn context_revision(&self) -> f64 { + self.inner.context_revision as f64 + } + #[wasm_bindgen(getter)] + pub fn pending(&self) -> Option { + self.inner.pending.clone() + } + #[wasm_bindgen(getter)] + pub fn game(&self) -> Result { + to_js(&self.inner.game) + } + #[wasm_bindgen(getter)] + pub fn disturbances(&self) -> Result { + to_js(&json!({"count": self.inner.disturbance_count, "last": self.inner.disturbance_last})) + } + /// Runs the body to `now`. Returns false when only `body()` fields changed. + pub fn advance(&mut self, now: f64, returning: bool) -> bool { + let before = ( + self.inner.revision, + self.inner.game.is_some(), + self.inner.state.offer.is_some(), + ); + let mut dice = self.overrides.dice(); + self.inner.advance(now, &self.seed, returning, &mut dice); + self.inner.revision > before.0 + 1 + || self.inner.game.is_some() != before.1 + || self.inner.state.offer.is_some() != before.2 + } + /// Restarts the body clock, so time spent elsewhere is not replayed. + #[wasm_bindgen(js_name = resetClock)] + pub fn reset_clock(&mut self, now: f64) { + self.inner.state.last_tick = now; + self.inner.revision += 1; + } + /// An owner command, the same JSON the host accepts. Throws the refusal. + pub fn command(&mut self, command: JsValue, now: f64) -> Result<(), JsError> { + let command: Command = from_js(command)?; + let mut dice = self.overrides.dice(); + self.inner + .command(command, now, &self.seed, &mut dice) + .map_err(|e| JsError::new(&e.0)) + } + #[wasm_bindgen(js_name = layEgg)] + pub fn lay_egg(&mut self, owner: &str, now: f64) -> Result<(), JsError> { + self.inner + .lay_egg(owner, now) + .map_err(|e| JsError::new(&e.0)) + } + /// Device preferences in `settings` (not `chatter`, which is a command). + #[wasm_bindgen(js_name = setPreference)] + pub fn set_preference(&mut self, key: &str, value: JsValue) -> Result<(), JsError> { + if key == "chatter" { + return Err(JsError::new("chatter is a pet setting")); + } + let value: serde_json::Value = from_js(value)?; + self.inner.state.settings.extra.insert(key.into(), value); + self.inner.revision += 1; + Ok(()) + } + /// Applies a line's tags as if the pet had said it (debug checks). + #[wasm_bindgen(js_name = applyLine)] + pub fn apply_line(&mut self, line: &str, now: f64) { + let mut dice = self.overrides.dice(); + self.inner.apply_line(line, now, &self.seed, &mut dice); + } + pub fn world(&self, now: f64) -> Result { + if self.inner.state.pet.is_none() { + return Ok(JsValue::NULL); + } + to_js(&self.inner.world(now, &self.seed)) + } + pub fn weather(&self, now: f64) -> String { + body::weather_at(&self.inner.state, now, &self.seed) + } + #[wasm_bindgen(js_name = dayNum)] + pub fn day_num(&self, now: f64) -> f64 { + body::day_num(&self.inner.state, now) as f64 + } + #[wasm_bindgen(js_name = wantTurn)] + pub fn want_turn(&mut self, kind: &str) { + self.inner + .want_turn(if kind == "idle" { "idle" } else { "react" }); + } + /// When the next idle turn may come. + #[wasm_bindgen(js_name = idleAt)] + pub fn idle_at(&mut self, at: f64) { + self.inner.next_idle = at; + } + #[wasm_bindgen(js_name = chooseTask)] + pub fn choose_task(&self, now: f64, background: bool) -> Result { + to_js(&self.inner.choose_task(now, background)) + } + #[wasm_bindgen(js_name = beginTurn)] + pub fn begin_turn(&mut self, now: f64) { + let mut dice = self.overrides.dice(); + self.inner.begin_turn(now, &mut dice); + } + #[wasm_bindgen(js_name = chatTurn)] + pub fn chat_turn(&self, kind: &str, now: f64) -> Result { + if self.inner.state.pet.is_none() { + return Err(JsError::new("an egg has no turns")); + } + Ok(ChatTurn(self.inner.chat_turn(kind, now, &self.seed))) + } + #[wasm_bindgen(js_name = commitLine)] + pub fn commit_line(&mut self, body: &str, now: f64) { + let mut dice = self.overrides.dice(); + self.inner.commit_line(body, now, &self.seed, &mut dice); + } + /// Answers the open offer when the brain said nothing usable. + #[wasm_bindgen(js_name = declineOffer)] + pub fn decline_offer(&mut self, now: f64) { + let mut dice = self.overrides.dice(); + self.inner.answer_offer(false, now, &self.seed, &mut dice); + } + #[wasm_bindgen(js_name = diaryTurn)] + pub fn diary_turn(&self, date: &str, now: f64, replace: bool) -> Option { + self.inner + .diary_turn(date, now, &self.seed, replace) + .map(DiaryTurn) + } + /// Commits a diary page; false when the text was filtered (the day stays pending). + #[wasm_bindgen(js_name = commitDiary)] + pub fn commit_diary(&mut self, turn: &DiaryTurn, generated: &str) -> bool { + match turn.0.finish(generated) { + Some(page) => { + self.inner.commit_page(page, turn.0.replace); + true + } + None => false, + } + } + /// Effects added since the last call, oldest first. + #[wasm_bindgen(js_name = takeEffects)] + pub fn take_effects(&mut self) -> Result { + let seen = self.seen; + let fresh: Vec<_> = self.inner.effects.iter().filter(|e| e.seq > seen).collect(); + self.seen = self.inner.effect_seq; + to_js(&fresh) + } + pub fn disturb(&mut self, kind: &str, now: f64) -> bool { + let mut dice = self.overrides.dice(); + self.inner.disturb(kind, now, &mut dice) + } + #[wasm_bindgen(js_name = resetDisturbances)] + pub fn reset_disturbances(&mut self) { + self.inner.reset_disturbances(); + } + /// Overrides wake rolls (browser checks); `null` restores `Math.random`. + #[wasm_bindgen(js_name = setWakeRoll)] + pub fn set_wake_roll(&mut self, roll: Option) { + self.overrides.wake = roll; + } + /// Overrides every random draw (replay tests); `null` restores `Math.random`. + #[wasm_bindgen(js_name = setRolls)] + pub fn set_rolls(&mut self, rolls: Option) { + self.overrides.rolls = rolls; + } + /// The whole engine as the host stores it, for cross-build checks. + #[wasm_bindgen(js_name = engineJson)] + pub fn engine_json(&self) -> Result { + Ok(serde_json::to_string(&self.inner)?) + } + /// Debug: makes the pet `age` milliseconds old. + #[wasm_bindgen(js_name = setAge)] + pub fn set_age(&mut self, age: f64, now: f64) { + if let Some(pet) = self.inner.state.pet.as_mut() { + pet.hatched = now - age; + pet.stage = body::stage_for(age).into(); + self.inner.revision += 1; + } + } + /// Debug: runs the body without the day log or chat lines. + pub fn simulate(&mut self, from: f64, to: f64, quiet: bool) -> Result { + let mut dice = self.overrides.dice(); + let events = + body::simulate_body(&mut self.inner.state, from, to, quiet, &mut || dice.roll()); + self.inner.revision += 1; + to_js(&events) + } + #[wasm_bindgen(js_name = alertPlan)] + pub fn alert_plan(&self, now: f64) -> Result { + let mut dice = self.overrides.dice(); + to_js(&body::alert_plan(&self.inner.state, now, &mut || { + dice.roll() + })) + } +} + +/// One pet line in progress: request, feed each generated piece, read the body. +#[wasm_bindgen] +pub struct ChatTurn(turn::ChatTurn); +#[wasm_bindgen] +impl ChatTurn { + #[wasm_bindgen(getter)] + pub fn kind(&self) -> String { + self.0.kind.clone() + } + pub fn request(&self) -> Result { + to_js(&self.0.request()) + } + /// True when the line needs another generation. + pub fn feed(&mut self, text: &str, reason: &str) -> bool { + self.0.feed(text, reason) + } + pub fn text(&self) -> String { + self.0.text().into() + } + pub fn body(&self) -> Option { + self.0.body().map(String::from) + } +} + +#[wasm_bindgen] +pub struct DiaryTurn(turn::DiaryTurn); +#[wasm_bindgen] +impl DiaryTurn { + #[wasm_bindgen(getter)] + pub fn date(&self) -> String { + self.0.date.clone() + } + pub fn request(&self) -> Result { + to_js(&self.0.request()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b1d02a0..ec4536e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,8 @@ 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). +Both run the same rules: the [shared Rust crate](../core/README.md) in +`core/`, linked natively by the host and compiled to WebAssembly for the page. 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 @@ -17,14 +19,15 @@ See `deployment.md` for configuration and privacy checks. ├─ bench.html test/bench.html with the release's model/runtime, unlinked ├─ sw.js site/sw.js: offline copies and alerts ├─ assets/ site/assets/* (font, manifest, icons) - ├─ app//*.js bundled main.js + brain.js, content-hashed together + ├─ app// main.js + brain.js + tokenling_core_bg.wasm, hashed together ├─ models// tokenizer + config + onnx/model_q4.onnx └─ runtime// transformers.min.js + ONNX Runtime Web .wasm/.mjs browser - ├─ main thread main.js Preact components + typed app controllers - │ scene.ts, sprites.ts and world.ts bundled inside - ├─ worker brain.js loads the model, streams tokens, stops on line/lookup + ├─ main thread main.js Preact components + typed app controllers; the + │ shared engine (WebAssembly) runs the pet's rules + ├─ worker brain.js loads the model, streams tokens; the shared stop and + │ context-fit rules from the same module ├─ service worker sw.js serves the offline copies, shows alerts ├─ localStorage "tokenling.v1" the whole pet (JSON, see pet-simulation.md) ├─ Web Lock "tokenling.v1" held by the one tab that runs the pet @@ -37,45 +40,48 @@ browser | File | Role | |---|---| -| `web/main.tsx` | Claims the tab, loads the save, mounts Preact and resumes the cached brain. | +| `core/src/`, `core/wasm/` | The shared rules (prompt contract, body, care, tag effects, turns, diary, saves) and their WebAssembly bindings. See [core](../core/README.md). | +| `web/core.ts` | Typed wrapper of the WebAssembly exports: lists, prompt/lookup/text helpers, stop/fit, saves and the `Engine` class. Loaded before boot in the page and in the worker. | +| `web/app/engine.ts` | The browser's engine instance: replaces/edits the save, runs local commands and refreshes the read-only `store.state` snapshot (body fields only on a plain tick). Changes hand their effects to `playEffect()`. | +| `web/main.tsx` | Loads the rules module, claims the tab, loads the save, mounts Preact and resumes the cached brain. | | `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 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/types.ts`, `web/runtime-types.ts` | State (mirroring the core's serde types), scene, worker request/response types and the small API surface used from the separately vendored transformers.js. | +| `web/app/store.ts` | The state snapshot, 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` | Browser wrappers of the core's save reading (device time zone) and guarded debounced persistence of the engine's JSON. | +| `web/app/simulation.ts` | Drives the engine's body clock: one-second ticks, quiet catch-up with the owner's return, clock resets after a transfer, and cached weather. | +| `web/app/conversation.ts`, `web/app/text.ts` | Runs the task the engine chooses (react/idle line, diary) while the brain is free, feeding each reply into the shared turn; 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/src/` | Rust private API, SQLite ownership/receipts, background scheduler and native ONNX inference around the shared engine. | | `server/src/routing.rs` | Numeric worker cost estimates, two-lane queue planning and age-based priority. | -| `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. | +| `server/tests/`, `server/src/bin/` | ownership/API/body tests, Python and native/WebAssembly parity, native inference probe. | +| `web/app/care.ts`, `web/app/diary.ts` | Egg progress and care actions as engine commands; writing the diary page the engine asks for. | +| `web/app/animation.ts`, `web/app/canvas.ts`, `web/app/game.ts` | Animation state and `playEffect()` for local and host effects, canvas attachment/cleanup, and catch physics/input. | | `web/app/brain.ts` | Model cache, download, upgrade, worker protocol and one-thread fallback. | | `web/app/pwa.ts`, `web/app/lifecycle.ts` | Offline resources, install prompts/alerts, Web Lock ownership and page lifecycle. | -| `web/app/commands.ts`, `web/app/debug.ts` | UI actions for setup/chat/settings/save management, and debug controls/prompt history. | +| `web/app/commands.ts`, `web/app/debug.ts` | UI actions for setup/chat/settings/save management (`petCommand()` runs locally or on the host), and debug controls/prompt history. | | `web/app/config.ts`, `web/app/utils.ts` | Host configuration, feature gates, constants and small shared helpers. | -| `web/brain.ts` | Module worker: imports the vendored runtime, loads the model on WebAssembly and streams/stops generation. Bundled as `brain.js`. | -| `web/world.ts` | Mirror of `train/world.py`: headers, lookup results, event lines and closed word lists. | +| `web/brain.ts` | Module worker: imports the vendored runtime, loads the model on WebAssembly and streams generation, fitting and stopping with the shared rules. Bundled as `brain.js`. | | `web/scene.ts`, `web/sprites.ts` | Existing 96×64 canvas renderer and pixel art, now typed. | | `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/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | Body, engine and save rules through the WebAssembly build, and compatibility with a real previous-app save. | +| `core/tests/rules.rs` | Native checks of saves, stop/fit, the line loop, offers, scheduling and catch-up. | | `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. | | `server/tests/native.rs`, `test/host-startup.py` | Opt-in real CPU/GPU/private-HTTP qualification and startup fallback. | -| `test/wasm-core-proof/` | Isolated build/interop proof of Rust prompt/lookup exports to JS; not imported by the app. | | `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. | | `site/sw.js` | Service worker beside the page: keeps what the page lists for offline use (cache first; the page itself network first), shows due alerts, opens the app from one. | -| `site/build.sh` | Fills `site/index.html` and `test/bench.html` from `site/release.json`, copies the app under its hash, the assets and the worker, and writes `_headers`. | +| `site/build.sh` | Builds the rules module, fills `site/index.html` and `test/bench.html` from `site/release.json`, copies the app under its hash, the assets and the worker, and writes `_headers`. | -`site/build.sh` runs the TypeScript checker and `site/bundle.mjs`, then hashes -the emitted app/worker bytes together. The host page loads `main.js` and supplies +`site/build.sh` runs `core/wasm/build.sh`, the TypeScript checker and +`site/bundle.mjs`, then hashes the emitted app/worker/rules bytes together. +The host page loads `main.js` and supplies only `#pet-root`, `#pet-config`, styles and static explanatory content. Preact owns all interactive markup. There is no hydration or server rendering. -The body remains independent of Preact: controllers change typed state and -notify subscribers at ticks, actions and token updates. Components own form +The body remains independent of Preact: controllers call the engine, which +changes the save, then notify subscribers at ticks, actions and token updates. Components own form 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`. @@ -95,42 +101,51 @@ 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. +Remote mode stops browser body/brain scheduling. The host's copy of the same +engine owns simulation, lookups, tags, memories and diary; returned effects +only animate the LCD, exactly as local effects do. The browser's engine then +only holds the latest checkpoint. 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 - message, an offer, a pat, cleaning, the lights, poop, waking, growing up, - coming back or the end of a game. **Idle** turns come every 8–20 s while the - tab is visible (minutes while asleep or with "lively" off), so the pet lives - on its own. `pump()` runs one turn at a time; react beats idle. -2. `petTurn()` renders the state header (`Wd.header("chat", worldState())`) and - asks the worker to continue `Name:` after the chat lines that fit (1024 - tokens for react turns, 512 for idle ones). -3. The worker streams tokens. It stops at a newline, or right after a complete - lookup tag (`[clock]`, `[look]`, `[recall:…]`, `[yesterday]`). -4. On a lookup stop, `petLine()` appends `{result}` computed by `Wd.toolResult` - and calls the worker again with the longer prefix. Up to three lookups per - line. -5. If new lines arrived while it was generating, the line is discarded and the - turn re-run with the new context. Otherwise the line is filtered - (`NOT_CUTE`), appended to `S.chat` (keeping at most two consecutive silent - lines), and `applyTags()` performs its effects: answer an open offer - (`[yes]` eats, bathes, takes medicine or starts the game), walk, animate, - show a want bubble, save a memory, put a gift on the shelf, note something - for the diary, fall asleep or show a dream. Sleeping offers wake on acceptance; - other sleeping lines only show dreams. An offer the pet saw but did not answer counts - as no. +The same engine schedules turns in both modes; the browser's `pump()` and the +host's dispatcher only run what it chooses while a brain is free. + +1. Something asks for a turn. **React** turns follow an owner message, an + offer, a pat, cleaning, the lights, poop, waking, growing up, coming back + or the end of a game. **Idle** turns come every 8–20 s (minutes while + asleep or with "lively" off), so the pet lives on its own. Today's diary + page is written at night once the pet sleeps; missed days follow after 30 + quiet seconds. React beats idle; a hidden local page answers reactions only. +2. The engine's chat turn renders the state header and asks the brain to + continue `Name:` after the chat lines that fit (1024 tokens for react + turns, 512 for idle ones; `fit` drops the oldest lines). +3. The brain streams tokens. The shared stop rule ends a line at a newline, + or right after a complete lookup tag (`[clock]`, `[look]`, `[recall:…]`, + `[yesterday]`). +4. On a lookup stop the turn appends `{result}` and asks again with the longer + prefix: up to three lookups per line. A line that fails `NOT_CUTE` is + generated again, three tries in all. +5. If the context changed while it was generating (the owner spoke, an offer, + a pat, the pet fell asleep), the line is discarded; whatever changed asked + for the next turn. Otherwise the engine commits it: the line joins the + chat (keeping at most two consecutive silent lines) and its tags take + effect: answer an open offer (`[yes]` eats, bathes, takes medicine or + starts the game), walk, animate, show a want bubble, save a memory, put a + gift on the shelf, note something for the diary, fall asleep or show a + dream. Sleeping offers wake on acceptance; other sleeping lines only show + dreams. An offer the pet saw but did not answer counts as no. 6. The chat view renders the line as plain text plus small "tool" chips, or a - silent line as a short action ("Mochi toddles left, bounces"). + silent line as a short action ("Mochi toddles left, bounces"). Effects + play on the LCD. The diary uses the same machinery with a `## tokenling diary` header, the day's -log as context and the prefix `dear diary,`; it runs to `<|endoftext|>` and -the app strips and stores `[doodle:…]` stickers. +log as context and the prefix `dear diary,`; it runs to a new header or a blank +gap, and the engine keeps up to three known `[doodle:…]` stickers and the +paragraphs. A page with unsafe text is dropped and its day stays pending. ## Where the model comes from @@ -154,8 +169,8 @@ See `training.md` and `export-and-runtime.md`. short generations (an idle turn every few seconds), so speed and prompt length matter more than eloquence. - **Context is 1024 tokens.** The header is about 150 tokens, so the chat view - sent to the model keeps only the newest lines that fit (`fit()` in - `brain.ts`). Long-term knowledge lives in the header's `remembers:` and + sent to the model keeps only the newest lines that fit (`fit` in + `core/src/world.rs`, counted by each brain's tokenizer). Long-term knowledge lives in the header's `remembers:` and `today:` lines and in lookups, not in raw history. - **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. @@ -167,29 +182,22 @@ See `training.md` and `export-and-runtime.md`. - **Speed.** About 35 tokens/s on a 4-thread desktop WebAssembly session (see `export-and-runtime.md`); most turns are a few tokens. -## Shared native/browser rules: design option - -A separate Rust rules crate could be used natively by the host and compiled -to WebAssembly for the browser. Prompt rendering, closed lists/lookups, body -math, tag effects and save validation are useful boundaries. Preact/canvas, -browser storage/lifecycle and browser inference remain in TypeScript; HTTP, -SQLite, queue routing and native inference remain in the host. Python still -needs independent training-format parity. This is a recommendation, not an -implemented migration or accepted change to the prompt contract. -An isolated [WASM proof](../test/wasm-core-proof/README.md) builds the existing -Rust world source directly and checks the JavaScript boundary separately. - -Start with prompt rendering and lookups, preserve byte-for-byte Python/native/ -WASM parity, then move simulation. Pass time/calendar information and random -draws explicitly so pure rules can run in both environments and replay tests. -Avoid sending the complete diary/transcript over the WASM boundary every -animation frame; return small body updates/effects and keep canvas frames in -JS. Save versions and unknown-field preservation must remain compatible. - -The browser bridge can use [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) -and [serde-wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html). -The current Node/esbuild-only frontend build would gain a Rust/WASM build and -bindings generation step. Its new immutable asset must enter the app hash and -offline cache, and core initialization must finish before reading/running a -pet. The host crate as a whole is not the browser target: its SQLite, native -ONNX and network dependencies need to stay outside the shared rules crate. +## Shared rules + +One Rust crate, [`core/`](../core/README.md), holds the prompt rendering, +closed lists and lookups, stop and fit rules, body math, care and tag effects, +turn scheduling, the diary and save reading. The host links it natively; the +page and its worker load the same code as WebAssembly through +[wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) and +[serde-wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html). +Preact/canvas, browser storage/lifecycle and browser inference stay in +TypeScript; HTTP, SQLite, queue routing and native inference stay in the host. +Python keeps independent training-format parity. + +Time, calendar and random draws are inputs, so both builds replay the same +scenario identically (`server/tests/parity.mjs`). The browser keeps the engine +inside the module and reads a snapshot after changes; a plain one-second tick +reads only the body fields, so the transcript and diary do not cross the +boundary every second, and canvas frames never do. The module is part of the +content-hashed app directory and the offline cache, and loads before the save +is read. diff --git a/docs/deployment.md b/docs/deployment.md index cb9161a..a4b085f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,13 +8,18 @@ what to publish, what the web server must do, and how to ship and roll back a mo ## Frontend build prerequisites Use Node.js 24 or newer and run `npm ci` from the repository before building. -`npm run typecheck` checks all TS/TSX with strict mode. `npm run build` writes -`dist/`; `site/build.sh OUT` remains the deployment/preview entry point. -It checks types and bundles Preact plus the app into `main.js`, and inference -into a separate `brain.js`, before replacing output. `site/bundle.mjs` accepts -an output directory argument and reads no environment variables. The builder -hashes emitted JavaScript and source maps together, so dependency changes also -get a fresh immutable path. Model and runtime assets are still published +The shared rules need Rust 1.88+ with the `wasm32-unknown-unknown` target and +`wasm-bindgen-cli` at exactly the version `core/wasm/Cargo.lock` pins +([core](../core/README.md)); `core/wasm/build.sh` names the missing piece. +`npm run typecheck` checks all TS/TSX with strict mode (after `npm run core`). +`npm run build` writes `dist/`; `site/build.sh OUT` remains the +deployment/preview entry point. It builds the rules module +(`tokenling_core_bg.wasm`), checks types and bundles Preact plus the app into +`main.js`, and inference into a separate `brain.js`, before replacing output. +`site/bundle.mjs` accepts an output directory argument, reads no environment +variables and copies the rules module beside both bundles. The builder hashes +emitted JavaScript, source maps and the module together, so dependency and +rule changes also get a fresh immutable path. Model and runtime assets are still published separately. No Node process, package registry or Preact CDN is needed to serve or use the built app. @@ -24,7 +29,7 @@ or use the built app. / the page (index.html), sw.js and _headers, from site/build.sh /bench.html unlinked brain benchmark using this release's model/runtime /assets/ the font, manifest.webmanifest and icons -/app// bundled main.js, brain.js and source maps, under a content hash +/app// bundled main.js, brain.js, source maps and tokenling_core_bg.wasm, under a content hash /models// exported models (see export-and-runtime.md) /runtime// the vendored transformers.js + ONNX Runtime files ``` @@ -122,7 +127,9 @@ Any static file server or static host works; nothing runs on the server. `Cache-Control: public, max-age=31536000, immutable`. This is only safe because those directories never change in place. Serve `sw.js` with `Cache-Control: no-cache` (or a short max-age) so worker updates arrive. -- **Compression** is optional; the `.onnx` and `.wasm` files barely compress. +- **Compression** is optional; the `.onnx` and runtime `.wasm` files barely + compress. The app's rules module (about 0.6 MB) shrinks to about a third + with gzip. - Allow for sizes: about 97 MB per model and about 85 MB of runtime (a browser fetches only one of the `.wasm` builds; the one transformers.js loads is 27 MB). @@ -138,8 +145,8 @@ The model's separate `transformers-cache` still supplies it offline. ## Shipping a change -**Code or page only** (nothing the model sees): run `node test/parity.mjs` and -`node test/contract.mjs`, preview with `test/dev-site.sh` (see `testing.md`), +**Code or page only** (nothing the model sees): run `npm test`, preview with +`test/dev-site.sh` (see `testing.md`), build with `site/build.sh` and copy the output to the server. **Keep old `app//` directories** when copying (no delete-sync of `app/`): a tab opened before the deploy may still start its brain worker from its own diff --git a/docs/export-and-runtime.md b/docs/export-and-runtime.md index c08704f..48d94e5 100644 --- a/docs/export-and-runtime.md +++ b/docs/export-and-runtime.md @@ -85,8 +85,9 @@ 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 +a native tokenizer. It preserves the QDQ/prepacking settings and empty +per-call KV, and takes the context fit, sampling settings and line/lookup stop +rules from the shared rules in `core/`, as the browser worker does. 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 @@ -133,8 +134,12 @@ and `wasmPaths = /`. It then loads `AutoTokenizer` and (`onnx/model_q4.onnx`), runs a one-token warmup and posts `ready`. Download progress is aggregated across files and drives the egg's cracks. -Each `generate` call takes a context budget (`ctx`; 1024 for react turns, 512 -for idle turns) and reports `prefillMs` (until the first new token), `tps` +Before loading the runtime the worker loads the shared rules module (beside +`brain.js`). Each `generate` call carries the request the shared turn built: +a context budget (`ctx`; 1024 for react turns, 512 for idle turns), the token +budget, temperature, top-k and top-p. The worker fits the prompt with the +shared `fit` (counting with the tokenizer), stops with the shared `stop` rule, +and reports `prefillMs` (until the first new token), `tps` (decoding speed after it) and `prompt`, the exact text it ran after trimming old lines to fit (shown in the debug panel, `docs/testing.md`). diff --git a/docs/extending.md b/docs/extending.md index 8533e2f..c3ef2b1 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -4,12 +4,16 @@ Anything the model must say or understand is a **retraining event**: the weights only know what was in the data. Anything purely visual or in the simulation is not. Each recipe says which it is. -After any change that touches `world.py`/`world.ts`, run `node test/parity.mjs`. +Rules shared by the browser and the host live once in `core/` (see +[its guide](../core/README.md)); never add them to `web/` or `server/` code. +After any change that touches `world.py` or `core/`, run `npm test`, build +`tokenling-contract` and run `node server/tests/parity.mjs`. ## Retrain loop -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. +1. Update `train/world.py` and `core/src/world.rs` together. Run + `node test/parity.mjs`, build `tokenling-contract` and run + `node server/tests/parity.mjs`. 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. @@ -22,13 +26,13 @@ After any change that touches `world.py`/`world.ts`, run `node test/parity.mjs`. Model-facing: -- `EMOTES` in `train/world.py` and `web/world.ts`. +- `EMOTES` in `train/world.py` and `core/src/world.rs`. App-facing: - `FACE` in `web/scene.ts` (eyes, mouth) and any motion branch in `drawPet`; - an entry in `ACT_WORD` in `web/app/text.ts` and, if it has a floating icon, - `applyTags()` in `web/app/conversation.ts`. + the `emote` branch of `playEffect()` in `web/app/animation.ts`. Retrain so the model produces it. A new word the model has not seen will not appear. (For a quick visual-only animation, add it to `FACE`/`drawPet` and @@ -38,21 +42,23 @@ trigger it from a care action with `animate()`; no retraining.) **An action tool** (like `[gift]` or `[walk:left]`): add the name to `EFFECT_TOOLS` in both world files (and a value list such as `WALKS`, which -`test/parity.mjs` compares), validate its arguments in `check_chat()`, handle -it in `applyTags()`, show a chip or an action word in `chipsFor()` / +`test/parity.mjs` compares), validate its arguments in `check_chat()`, apply +it in `Engine::apply_line` in `core/src/engine.rs` (emit an effect for the LCD +and play it in `playEffect()`), show a chip or an action word in `chipsFor()` / `ACT_WORD`, describe it in the teacher prompt, then retrain. Prefer a tag for anything the pet decides; the app should only carry out the decision. **A new offer** (something the owner proposes and the pet accepts or refuses): add the event line to `event_patterns()` and `offer_patterns()` in -`world.py`, the builder to `events` in `world.ts`, a branch in `offer()` / -`answerOffer()` in `web/app/care.ts`, a `NO_POOL` entry and scenarios in +`world.py`, the builder to `events` in `core/src/world.rs` (and `event()` in +`core/wasm/src/lib.rs` for the parity test), branches in the `Care` command and +`answer_offer` in `core/src/engine.rs`, a `NO_POOL` entry and scenarios in `gen_data.py`, and a case in the parity test's event list. Then retrain. **A lookup tool** (like `[clock]`): in addition to the above, add it to -`LOOKUP_TOOLS` and `tool_result()` / `toolResult()` with identical logic in -both languages, and update the three stop regexes: `LOOKUP_END` in -`web/brain.ts` and `train/eval_pet.py`, and `LOOKUP_TAIL` in `web/app/conversation.ts`. +`LOOKUP_TOOLS` and `tool_result()` with identical logic in `world.py` and +`core/src/world.rs`, and update the two stop regexes: `LOOKUP_END` in +`core/src/world.rs` (both brains stop with it) and in `train/eval_pet.py`. Mention it in the static part of the teacher prompt and add scenarios that call it. Results must be deterministic functions of the prompt state, because the validator replaces whatever the teacher wrote. @@ -63,14 +69,15 @@ Add the value to `DREAMS` in both world files and provide its sprite. Foods and gifts are included automatically. `dream_thing`/`dreamThing` supplies the day-log and chip label. Update sleep teacher coverage and validation, retrain, and ship a new model. Preserve the closed sleeping grammar and bounded dream -log. Body-only wake tuning uses `wake_chance`/`wakeChance`; parity covers it. +log. Body-only wake tuning uses `wake_chance` in `core/src/world.rs` and +`world.py`; parity covers it. ## Add or change a food, gift or want - Lists: `FOODS`, `GIFTS` or `WANTS` in both world files. - Art: add a sprite to `SPRITES` in `web/sprites.ts` (rows of 8 characters, see `KEY` for the palette) and, for wants, `WANT_ICON` in `web/app/config.ts`. -- Behavior: feeding effects are in `answerOffer()` in `web/app/care.ts`. +- Behavior: feeding effects are in `answer_offer` in `core/src/engine.rs`. - Foods and gifts appear in prompts, so this is a retraining event. A food that exists in the app but not in the training data confuses the model. @@ -78,16 +85,17 @@ log. Body-only wake tuning uses `wake_chance`/`wakeChance`; parity covers it. - Names: `STAGES` in both world files; voice text in `STAGE_STYLE` in `gen_data.py`; sampling weights in `rand_state()`. -- App: `STAGE_AGE` in `web/app/config.ts`, a `SIZE` entry and decorations in `scene.ts`. +- App: `STAGE_AGES` in `core/src/body.rs`, a `SIZE` entry and decorations in `scene.ts`. - The prompt only contains the stage name and day number, so changing **ages** alone is not a retraining event, but new stage names are. ## Change the simulation -Rates, poop timing, sleep rules and minigame tuning are constants in -`web/app/body.ts` (`rates()`, `simulateBody()`) and `web/app/game.ts` -(`stepGame()`). Keep them in sync with -`pet-simulation.md`. If you change the **stat words** or thresholds +Rates, poop timing and sleep rules are constants in `core/src/body.rs` +(`simulate_body`); care, offers, scheduling and the day log are in +`core/src/engine.rs`. Minigame tuning is in `web/app/game.ts` (`stepGame()`). +Keep them in sync with `pet-simulation.md`; `node server/tests/parity.mjs` +checks that both builds still agree. If you change the **stat words** or thresholds (`levelWords`), it is a retraining event: the model learned those exact words. ## Change the look of the page @@ -100,11 +108,12 @@ and run `node test/contract.mjs` if you rename or remove an element. ## Add a stored field -Add it to `fresh()`. A new top-level key is filled for older saves by the -`...fresh()` spread, and a new setting whose absence means "off" needs nothing. -Nested changes or renames need `VERSION` bumped and a step appended to -`MIGRATIONS` in `web/app/save-format.ts` (it receives the previous version's object and must -return one with `v` one higher). Never make `load()` accept a newer version: +Add it to `PetState` in `core/src/types.rs`, `fresh()` in `core/src/save.rs` +and `PetState` in `web/types.ts`. A new top-level key is filled for older +saves from a new save's defaults, and a new setting whose absence means "off" +needs nothing. Nested changes or renames need `VERSION` bumped and a step +appended to `MIGRATIONS` in `core/src/save.rs` (it receives the previous +version's object and must return one with `v` one higher). Never make `load()` accept a newer version: it shows the bad egg instead, and the save stays untouched. Test with an old save (export one before the change) and document it in `pet-simulation.md`. @@ -122,7 +131,7 @@ empty `older` instead (`deployment.md`). ## Change the base model v1 used GPT-2 Medium; v2 and later use GPT-2 small on purpose (a smaller, sillier pet -and a 97 MB download). A different size or tokenizer affects `fit()`'s context +and a 97 MB download). A different size or tokenizer affects `fit`'s context budget, the `][` handling, `quantize.py` (the embedding name and head layout in `embedding_int8()`, the head count in `check_onnx.py` and `quant_eval.py` comes from the config), and every size on the page. Re-run `quant_eval.py`, diff --git a/docs/gotchas.md b/docs/gotchas.md index c0ddd1c..9b80e29 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -8,7 +8,8 @@ what you see, why, and what to do. **Every line starts with "ick", "icky", or other odd repeated words.** The prompt ended with `Name: ` (trailing space). GPT-2 has `" word"` tokens with the space attached, so a bare space token derails sampling. End prompts with -`Name:`. Applies to the app (`petLine`), `eval_pet.py` and any new tool. +`Name:`. Applies to the shared chat turn (`core/src/turn.rs`), `eval_pet.py` +and any new tool. **Tags come out mangled: `[want_play]`, `[clock speed]`, `[got]`.** `repetition_penalty` in transformers.js penalizes tokens already in the @@ -18,7 +19,7 @@ garbage; removing it fixed them. **`[look][gift:flower]` runs two tools together, or the lookup never stops.** GPT-2 has a single `][` token, so the stop regex that expects a clean `]` end -never fires. The stopper accepts an optional trailing `[` and the app trims it. +never fires. The shared stop rule accepts an optional trailing `[` and trims it. **A format change that "looks harmless" breaks the pet.** The model learned the exact header, word lists and stat thresholds. See `AGENTS.md`. @@ -38,11 +39,11 @@ most common v2 rejection). anything left; the app also strips `*` from owner input. **The base model can say explicit things.** GPT-2 was trained on the open web. -`NOT_CUTE` in `web/app/text.ts` filters and retries; the fine-tune makes it rare, +`NOT_CUTE` in `core/src/world.rs` filters and retries; the fine-tune makes it rare, not impossible. Keep the filter. **Chat history seems ignored.** The context is 1024 tokens and the header takes -about 150. `fit()` drops the oldest chat lines first. Durable facts belong in +about 150. `fit` drops the oldest chat lines first. Durable facts belong in `remembers:` / `today:` or behind a lookup. ## Data generation @@ -324,3 +325,27 @@ length. Bind KV outputs to CUDA memory, retain their Values as the next inputs, and bind logits to the CPU for sampling. Recreate bindings per generation, use fresh output bindings as cache shapes grow, and verify repeated CPU/CUDA greedy agreement and cancellation before accepting a speed improvement. + +## Shared rules (core) + +**Editing `tokenling.state()` in a browser check changes nothing.** The pet +lives in the shared engine inside the WebAssembly module; `store.state` is a +snapshot replaced after every change. Use `tokenling.debug.edit((s) => …)` +(with `?debug=1`), which validates the edited save and loads it into the +engine without dropping queued turns. + +**`core/wasm/build.sh` refuses to run after a dependency update.** wasm-bindgen's +generated glue only works with the CLI of exactly the crate's version. Install +the version `core/wasm/Cargo.lock` pins (`cargo install wasm-bindgen-cli +--version X --locked`) rather than unpinning the crate. + +**A Node test throws `document is not defined`.** Some browser modules read +the page at import (`web/app/config.ts`). Modules the Node tests import +(`web/core.ts`, `web/app/engine.ts`, `storage.ts`, `save-format.ts`) must not +import them; effects reach the canvas through the hook `lifecycle.ts` +registers. + +**A local pet's clock differs from the same pet on a host after travel.** The +host converts times with the save's `hosting.timeZone`; the browser uses the +device's current zone. Moving to a host records the device's zone first. The +parity replay runs in two zones, but only with the same zone on both sides. diff --git a/docs/host-page.md b/docs/host-page.md index eccc89d..0816798 100644 --- a/docs/host-page.md +++ b/docs/host-page.md @@ -45,7 +45,7 @@ URL and the worker's scope is the page's directory. | `@MODELS@` | URL prefix holding model directories, with a trailing slash | | `@MODEL_ID@` | model directory name | | `@SIZE@`, `@SIZE_MB@` | size of `onnx/model_q4.onnx` in bytes, and in MB for the text | -| `@APP@` | URL of the directory holding the bundled `main.js` and `brain.js` | +| `@APP@` | URL of the directory holding the bundled `main.js`, `brain.js` and `tokenling_core_bg.wasm` | | `@ASSETS@` | URL of the directory holding `site/assets/*` | | `@TRAINED@` | one line about the model build for the details list | | `@SOURCE@` | URL of the source code | @@ -107,11 +107,14 @@ without requiring JavaScript. | `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 -prompt helpers; it starts `brain.js` as a module worker relative to itself. -`site/build.sh` hashes the emitted bundles and maps, including dependencies, -so a build never mixes versions. Keep app, model and runtime on the same origin. +`main.js`, `brain.js` and `tokenling_core_bg.wasm` must be served together +under the hashed app directory (`.wasm` as `application/wasm`). The main +bundle includes Preact, all components/controllers and the renderer; both +bundles load the shared rules module from beside themselves before running, +and the page starts `brain.js` as a module worker relative to itself. If the +module cannot load, `#pet-root` shows a short error instead of the app. +`site/build.sh` hashes the emitted bundles, maps and module, including +dependencies, so a build never mixes versions. Keep app, model and runtime on the same origin. The app adds the class `pet-js` to `` once it has booted. Use it to keep the interactive area hidden when JavaScript is off while leaving the diff --git a/docs/pet-simulation.md b/docs/pet-simulation.md index 6a71a58..5d91bb4 100644 --- a/docs/pet-simulation.md +++ b/docs/pet-simulation.md @@ -1,12 +1,15 @@ # Pet simulation, storage and UI -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. +The rules on this page (body, care, offers, tags, the day log, diary and turn +scheduling) live once in the shared engine in `core/` (see +[its guide](../core/README.md)). In local mode the page runs it as +WebAssembly; `web/app/` drives its clock and turns, and plays its effects. 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. 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. +aging while the tab is closed. In remote mode the Rust host runs the same +engine natively and also generates thoughts while the page is closed; the +browser renders synchronized state. The host uses the save's time zone; a +local pet uses its device's clock. The split is strict: **the brain decides, the app runs the body.** Stats, poop, sickness, collapsing from exhaustion and waking up rested are @@ -72,14 +75,15 @@ Four stats, 0–100: `tummy`, `fun`, `energy`, `clean`. Change per hour: | awake | −8 | −6 | −5 | −3 | | asleep | −2 | 0 | +22 | −0.5 | -While sick, `fun` falls twice as fast. The tick runs once a second. After the -tab was hidden or closed, `catchUp()` replays up to seven days in 10-minute -steps and writes what happened into the day log ("was very hungry", "was -stinky", "pooped", "napped", " was away for a while"). Away for 30 -minutes or more adds `* OWNER came back after … *` and a reaction. Catch-up -also advances the live tick's clock, so the next animation frame does not -apply the same elapsed time again; gaps of two minutes or less are still -handled once by the live tick. +While sick, `fun` falls twice as fast. The tick runs once a second. Any gap +over two minutes (a hidden or closed tab, a host restart) is a quiet +catch-up: up to seven days in 10-minute steps, writing what happened into +the day log ("was very hungry", "was stinky", "pooped", "napped"). When the +owner reopens a local page after 30 minutes or more, the pet also gets +`* OWNER came back after … *`, " was away for a while" and a reaction. +Catch-up advances the body clock (`lastTick`), so the next tick does not +apply the same elapsed time again. An offer left open while nobody ran the pet +is dropped during catch-up. - **Poop:** every 2.5–5 awake hours, up to four. Each costs 15 cleanliness. - **Sickness:** three or more poops, or `tummy` or `clean` below 5, for an hour @@ -112,7 +116,8 @@ Feeding, baths, medicine and catch are **offers**: the app writes the offer event line, shows the item bobbing beside the pet, and asks the brain. The effect happens only if the pet's answer contains `[yes]`; `[no]` (or no answer) logs "said no to …" in the day log. One offer can be open at a time, and an -offer left open when the page closed is dropped. +offer left open when the page closed is dropped. A second offer while one is +open is refused ("still deciding"), as is cleaning with no poop. Care choices stay available regardless of needs. For example, a healthy pet can still be offered medicine; training teaches it to decline, and medicine @@ -146,7 +151,7 @@ in a field and navigating tabs do not move the pet. Stars score +1, ghosts −1 ## The day log and diary -`S.today.log` holds up to 14 short entries: foods eaten, baths, pats, games, +`today.log` holds up to 14 short entries: foods eaten, baths, pats, games, medicine, naps, offers it turned down, things the pet learned or was told, gifts it gave, dreams and notes it made with `[diary:…]`. At most two dream entries are retained; an identical dream already there is not logged again. @@ -159,38 +164,42 @@ a page. The debug panel has a write/rewrite control for testing (see [testing](testing.md#debug-panel)). A page is written: -- when the pet falls asleep at night (21:00–04:59) with at least two log - entries and no page yet for that date (`onSleep()`), or -- for a missed day: at local midnight `rollDay()` moves a day with at least two - entries and no page into `S.history` (days with fewer are dropped; only the - newest seven are kept), and `maybeDiary()` has the pet write them one at a - time, oldest first, when the brain is free, the tab is visible and the owner +- while the pet sleeps at night (21:00–04:59) with at least two log entries + and no page yet for that date, or +- for a missed day: at local midnight the engine moves a day with at least two + entries and no page into `history` (days with fewer are dropped; only the + newest seven are kept), and the pet writes them one at a time, oldest + first, when the brain is free, the tab is visible (locally) and the owner has not interacted for 30 seconds. A toast names the date. +The engine's `choose_task` decides this in both modes. A page whose text fails +the cute filter is dropped whole; its day stays pending and is tried again. + The "today so far" line on the diary tab updates whenever the day log changes. Diary generation needs the brain. Pages store the date, day number, weather, stage, text and up to three doodle names. -Weather is deterministic per date and half-day (`weatherAt`), so the window, +Weather is deterministic per date and half-day (`weather_at`), so the window, the `now:` line and diary headers agree. ## Turns: react and idle -`wantTurn()` queues a brain turn and `pump()` runs one at a time. +The engine keeps the requested turn and its idle timer; `pump()` (browser) +and the host's dispatcher run one task at a time when the brain is free. - **React** turns follow anything that happened: an owner message, an offer, a pat, cleaning, the lights, poop, waking, growing up, coming back, the end of a game. A react request replaces a waiting idle one. - **Idle** turns let the pet live: when the brain is ready, nothing is queued, - no game runs and the tab is visible, the pet gets a turn every 8–20 s + no game runs and (locally) the tab is visible, the pet gets a turn every 8–20 s ("lively", the default) or every 2–4 minutes with the setting off, and every 3–6 minutes while asleep. Idle turns use a 512-token context. Their lines are usually silent: walking, an emote, a want, sometimes a mutter or a lookup. -- If a new line arrives while a turn is generating, the result is thrown away - and the brain thinks again with the new context (at most twice in a row). - A sleep-state change always discards the stale result, including a body - collapse that wrote no chat event. +- If the context changes while a turn is generating (a new line, an owner + command or the pet falling asleep, including a body collapse that wrote no + chat event), the result is thrown away; the change itself asked for the next + turn, which sees the new context. - While a react turn generates, the LCD shows a "…" thought bubble and the chat shows the pet's bubble with three bouncing dots until its first words stream in. @@ -206,7 +215,7 @@ transfer intent use the separate `tokenling.remote.v1` browser key and are excluded from exported local recovery saves. ``` -v: 2 save version (VERSION in web/app/save-format.ts) +v: 2 save version (VERSION in core/src/save.rs) created, lastTick, lastActive, grimeSince, nextPoop ms timestamps owner name the pet calls them egg: { laid } | null @@ -231,13 +240,15 @@ hosting: { mode: "local" | "remote", brainId: string | null, timeZone: string } Because `chat[]` stores exactly what the model sees, an export is a faithful transcript. New optional fields whose absence means "off" or "empty" need no -version bump (`load()` fills missing top-level keys from `fresh()`; a missing -setting reads as false). Changing or renaming a stored shape means bumping -`VERSION` and adding a step to `MIGRATIONS` in `web/app/save-format.ts`. +version bump (reading fills missing top-level keys from a new save; a missing +setting reads as false). Reading also validates the save as the host does: +names, closed lists (stage, traits, toy, offer kind), need ranges, timestamps +and history limits. Unknown fields are kept. Changing or renaming a stored shape means bumping +`VERSION` and adding a step to `MIGRATIONS` in `core/src/save.rs`. ### Save versions and the bad egg -`parseSave()` reads a save: it migrates an older version one step at a time +`parse_save` in `core/src/save.rs` (`parseSave()` in the page) reads a save: it migrates an older version one step at a time (`MIGRATIONS[n]` turns version n+1 into n+2), and refuses a save that is not JSON, has no integer `v`, lacks `chat`, `diary` or `stats`, has no migration step, or comes from a **newer** version than the page. A refused save is never diff --git a/docs/prompt-format.md b/docs/prompt-format.md index 8fc692c..3edfa63 100644 --- a/docs/prompt-format.md +++ b/docs/prompt-format.md @@ -1,12 +1,13 @@ # Prompt format and tool grammar This is the contract between the app and the weights. `train/world.py` renders -it for training, `web/world.ts` for the browser, and `server/src/world.rs` for -the optional Rust host; `node test/parity.mjs` +it for training; `core/src/world.rs` renders it for the browser (as +WebAssembly) and the optional Rust host (natively). `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 -offers). +offers). `node server/tests/parity.mjs` repeats the prompt comparison for the +native build. ## Chat prompt @@ -72,8 +73,8 @@ only way the pet learns what the owner did or what happened to its body. * Sam came back after 3 hours * * Mochi grew into a teen! * ``` -`world.py:event_patterns()` is the validator for training data; `world.ts:events` -builds them. Foods take "a" or "an" (`a(food)`). +`world.py:event_patterns()` is the validator for training data; `events` in +`core/src/world.rs` builds them. Foods take "a" or "an" (`a(food)`). Messages and pats may wake a sleeping pet by a body roll; a successful roll writes `* Mochi woke up *` before the message or pat. Lights on rolls after @@ -143,7 +144,7 @@ contain `]` or a newline. | `[yesterday]` | the first sentence of the last finished diary page, or `no diary yet` | The exchange looks like `[clock]{21:40 night}`. During generation the model -stops after the closing `]` (or the single token `][`, which `brain.ts` trims), +stops after the closing `]` (or the single token `][`, which the shared stop rule trims), the app appends `{…}`, and the model continues the same line. In training data the `{result}` is part of the context but carries no loss, so the model learns to call the tool, not to fake the answer. @@ -154,19 +155,24 @@ matches on any shared word. ## Stopping and trimming +Both brains (the browser worker and the host's native sessions) use the same +rules from `core/`: `stop` and `fit` in `world.rs`, budgets and sampling in +each `Generation` that `turn.rs` builds. + - Chat generation stops at the first newline or a completed lookup tag and allows at most 64 new tokens per hop (40 for idle turns). Sampling is temperature 0.85, top-k 40, top-p 0.92. -- Diary generation allows 220 tokens and also stops on a stray `\n##` or a run - of blank lines. -- `fit()` in `brain.ts` drops the oldest chat lines until header + lines + - prefix + the new-token budget fit in 1024 tokens (512 for idle turns, whose - cost is mostly prompt processing). +- Diary generation allows 220 tokens and stops at a stray `\n##` or three + newlines, keeping the text before it. +- `fit` drops the oldest chat lines until header + lines + prefix + the + new-token budget fit in 1024 tokens (512 for idle turns, whose cost is + mostly prompt processing), counting with the brain's tokenizer. A header + that cannot fit is an error, never a truncated header. ## Tokenization pitfalls - `Name:` and `Name: ` tokenize differently. Always end the prompt with `Name:`; the model emits `" word"` tokens itself. - GPT-2 has a merged `][` token, so `[look][gift:x]` closes a lookup - mid-token. The stopper regex allows a trailing `[`, and the app removes it. + mid-token. The stop rule allows a trailing `[` and removes it. - Do not use a repetition penalty (see `gotchas.md`). diff --git a/docs/testing.md b/docs/testing.md index 4258055..be8cff1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,12 +3,14 @@ | 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, 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 | +| Shared rules build | `npm run core` (`core/wasm/build.sh`) | Rust/wasm-bindgen toolchain mismatch; produces `core/wasm/pkg/` for every check below | +| Shared rules (native) | `cargo test --locked --manifest-path core/Cargo.toml` | save reading/refusal, stop and fit rules, the lookup line loop and filtered retries, offers, scheduling (react, bedtime and missed diary, idle), catch-up | +| Engine, body and saves | `node --test test/*.test.mjs` | the WebAssembly build's body rules, offers, sleeping answers and wake rolls, turns and lookups, silent lines, the owner's return, v1-to-v2 local migration, bad eggs and ownership/reset write guards | +| Prompt parity | `node test/parity.mjs` | drift between the core's WebAssembly build and Python 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 | | Native serving (opt-in) | release `--test native -- --ignored --test-threads=1`, with explicit runtime/model/profile/output env | real CPU/CUDA parity, near-limit prompts, lookup continuation, concurrent private HTTP and in-flight transfer fencing | | Actual startup fallback (opt-in) | `python3 test/host-startup.py --help` for required inputs | missing CUDA provider keeps real CPU serving, authentication, CORS, no-store and transfers | -| Rust prompt parity | build `tokenling-contract`, then `node server/tests/parity.mjs` | Rust/Python headers, lookups, 15 lists and wake probabilities across 300 states | +| Native parity and replay | build `tokenling-contract` and the WebAssembly rules, then `node server/tests/parity.mjs` | native/Python headers, lookups, 15 lists and wake probabilities across 300 states; a 31-step engine scenario replayed natively and in WebAssembly with identical JSON after every step, in UTC and in a daylight-saving zone | | 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 | @@ -20,10 +22,12 @@ Run `npm ci` first (Node.js 24+). `npm run format` formats the frontend and new build/test scripts; `npm run format:check` checks without writing. -`npm test` runs strict type checking, prompt parity, the component/host contract, -Node's built-in body/save tests, Python data rules and script lifecycle checks. -Node executes the erasable TypeScript in the pure modules directly; -no separate test transpiler or browser framework is required. +`npm test` builds the shared rules to WebAssembly, then runs strict type +checking, prompt parity, the component/host contract, Node's built-in +engine/body/save tests, Python data rules and script lifecycle checks. It needs +the Rust toolchain from [core](../core/README.md). Node executes the erasable +TypeScript in the pure modules directly and loads the module with +`initCoreSync()`; no separate test transpiler or browser framework is required. Real browser checks use the `playwright-cli` skill. A repeatable regression flow, including a real model download, is available for a **fresh, disposable @@ -275,14 +279,40 @@ them. Panels fit at 1280, 768 and 360 px. Both test pets were deleted through the API, leaving no pet or operation rows. The service used about 0.6 GiB of memory. One deployment; no load, multi-day or phone checks. -## Shared rules WASM proof (2026-10-09) - -The opt-in [proof](../test/wasm-core-proof/README.md) compiles existing Rust -prompt/lookup code to WebAssembly with a JS object bridge. Node.js and a real -desktop Chromium page each matched Python across 300 states and 15 lists. -Generated WASM is about 1.13 MB raw/346 KB gzip, plus 19 KB raw JS glue. -This validates feasibility and the interop boundary; the app still uses its -current TypeScript rules, and the shared-core migration has not started. +## Shared rules migration check receipt (2026-10-10) + +The rules the browser and the host both apply moved into `core/` (one Rust +crate, native in the host and WebAssembly in the page and worker). It replaced +the browser's TypeScript prompt and body modules, the host's own save/prompt +modules and both runtimes' copies of care, tags, turns, diary and save rules. +The earlier isolated WebAssembly proof (2026-10-09) is superseded and was +removed. Measured on one Linux desktop: headless Chromium, the real v6 q4 +export, native CPU ORT with four threads. These are functional checks, not +speed measurements. + +- `npm test`: core build, strict types, 300 parity states/15 lists with zero + mismatches through the WebAssembly build, the page contract, 13 Node + engine/body/save tests (the real v1 fixture migrates losslessly), 20 + data-rule tests and 4 script lifecycle tests. Formatting checks passed. +- `cargo test` for `core/` (6 tests) and `server/` (17 tests, host tests + unchanged in substance) passed; the release `native` test compiled. + `node server/tests/parity.mjs`: 300 native/Python states and 15 lists with + zero mismatches, and the 31-step engine replay identical in both builds in + UTC and America/Los_Angeles (across the November DST change). +- `test/browser-flow.js` passed against a local preview: fresh download, + hatch and real generation, chat, food and healthy-medicine offers, + memory/shelf, a real diary page (paragraphs kept), catch controls, keyboard + tabs, all panels at 1280/768/360 px, cached reload and Web Lock takeover. + No browser console errors or warnings. +- `test/hosting-flow.js` passed in 32 s against that preview and a local + release host with the real native CPU brain: remote creation with a lost + activation acknowledgement and no local model download, closed-page body + progress, both transfer directions preserving identity, offline recovery, + a second (loopback alias) host and eventual stop, and 1280/768/360 px. The + only console errors were the flow's injected network failures. +- Not re-tested for this change: physical phones, Firefox, install prompts, + background alert delivery, upgrades between two releases, the 90-second + one-thread fallback and CUDA serving. ## Preview server @@ -424,21 +454,22 @@ playwright-cli -s=pet run-code "async page => { return await page.evaluate(() => window.tokenling.state().chat.join('\n')); }" ``` -`window.tokenling` exposes `state()` (the live state object, so tests can set -stats directly), `brain` (including `id`, the running model, and `tps` and +`window.tokenling` exposes `state()` (a read-only snapshot of the engine's +save; with `?debug=1`, change it with `debug.edit((s) => { s.stats.tummy = 15 })`), `brain` (including `id`, the running model, and `tps` and `prefillMs` of the last turn), `upgrade`, `view`, `act(kind, arg)`, `petTurn(kind?)`, `wantTurn(kind?)`, `renderAll()` and `writeDiary(dateKey?, rewrite?)` (`rewrite=true` is honored only for today in -debug mode; tests can call this or -seed `state().history` with a past day and wait for `maybeDiary()`). +debug mode; tests can call this or seed `history` with a past day through +`debug.edit` and let the engine schedule the missed page). **Sleep:** in debug mode, `tokenling.debug.setWakeRoll(() => 0.5)` fixes only disturbance randomness; `setWakeRoll(null)` restores it. Reset the counter with `resetDisturbances()`. At energy 10 a first message stays asleep with that roll; at energy 70 it wakes. A roll of 0.99 leaves pats and lights asleep. Offer a cookie while hungry and while stuffed: the offer itself never wakes; -only an accepted reply writes `woke up` and eats. Debug also exposes `applyTags` -and `simulate` for body checks. Confirm the sleep grime timer pauses and +only an accepted reply writes `woke up` and eats. Debug also exposes +`applyTags` (the engine applies the tags as if the pet wrote them), `simulate` +and `edit` for body checks. Confirm the sleep grime timer pauses and resumes, and no more than two dream entries crowd the day log. Optional `eval_pet.py CKPT 80 REPORT.json` saves metrics and all new probe diff --git a/package.json b/package.json index a7dc956..e46b3d2 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "scripts": { "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", + "test": "npm run core && 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 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" + "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", + "core": "core/wasm/build.sh" }, "dependencies": { "preact": "11.0.0" diff --git a/server/Cargo.lock b/server/Cargo.lock index 7eef53d..045a09e 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1037,6 +1037,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -1350,6 +1356,18 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokenling-core" +version = "0.1.0" +dependencies = [ + "chrono", + "chrono-tz", + "js-sys", + "regex-lite", + "serde", + "serde_json", +] + [[package]] name = "tokenling-server" version = "0.1.0" @@ -1357,10 +1375,8 @@ dependencies = [ "anyhow", "axum", "chrono", - "chrono-tz", "ort", "rand", - "regex", "rusqlite", "serde", "serde_json", @@ -1368,6 +1384,7 @@ dependencies = [ "subtle", "tempfile", "tokenizers", + "tokenling-core", "tokio", "tower", "tower-http", diff --git a/server/Cargo.toml b/server/Cargo.toml index 04e95f3..eaa4e89 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,15 +10,14 @@ description = "Self-hostable tokenling pet host with native ONNX inference" 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" +tokenling-core = { path = "../core" } 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"] } diff --git a/server/README.md b/server/README.md index eef4aab..42597e2 100644 --- a/server/README.md +++ b/server/README.md @@ -2,7 +2,9 @@ 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 +runs the existing exported brain. The pet's rules come from the shared crate +in [`core/`](../core/README.md), the same code the browser runs as +WebAssembly in local mode. 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. @@ -111,8 +113,10 @@ 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 +The host links the shared engine for body rates, growth, care/offer effects, +the wake formula, closed tags, deterministic lookups, turn scheduling, the +diary and trained prompts; it adds ownership, epochs, error back-off and +workers around it. Stored rows keep the engine's fields at the top level. 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 @@ -163,15 +167,19 @@ guaranteed closed-browser notification delivery. ```sh cargo fmt --check --manifest-path server/Cargo.toml cargo test --locked --manifest-path server/Cargo.toml +cargo test --locked --manifest-path core/Cargo.toml cargo build --locked --manifest-path server/Cargo.toml --bin tokenling-contract +core/wasm/build.sh 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, lane preference/serialization, body rules and sleeping offers. Rust/Python parity -covers 300 states and 15 lists, lookups and wake probabilities. Browser checks +persistence, lane preference/serialization, body rules and sleeping offers. Native/Python parity +covers 300 states and 15 lists, lookups and wake probabilities; the replay +then runs one 31-step engine scenario natively and in WebAssembly, in two time +zones, and requires identical engine JSON after every step. Browser checks are in [testing](../docs/testing.md). For real-model checks, compile before borrowing a GPU and supply explicit @@ -214,13 +222,12 @@ stay outside Git. Both tests bind ephemeral loopback listeners. | 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/host.rs` | Private API, dispatch of the engine's tasks to workers, and commit fencing | | `src/routing.rs` | Bounded numeric timing estimates, queue plans and wait aging | | `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/simulation.rs` | `HostedPet`: the shared engine plus ownership, epoch and error fields | +| `src/brain.rs` | Tokenizer, native inference and sampling, using the shared fit and stop rules | +| `src/bin/contract.rs`, `tests/parity.mjs` | Prompt/list/lookup/wake parity with Python, and the native/WebAssembly engine replay | | `src/bin/brain-probe.rs` | Generation object or array from stdin, reusing one session; required `ORT_DYLIB_PATH`, `TOKENLING_MODEL_DIR`, optional `TOKENLING_THREADS=4`, `TOKENLING_GPU_DEVICE`. Reports token counts/timing; diagnostic stdout includes the prompt. | | `tests/host.rs` | API, state-ownership, body and generation regressions | | `tests/native.rs` | Opt-in real CPU/GPU comparison and concurrent private HTTP/ownership checks | diff --git a/server/src/bin/contract.rs b/server/src/bin/contract.rs index 7eeac7d..f6d6b25 100644 --- a/server/src/bin/contract.rs +++ b/server/src/bin/contract.rs @@ -1,34 +1,187 @@ -//! JSON stdin/stdout adapter for Python/TypeScript/Rust prompt parity checks. +//! JSON stdin/stdout adapter for contract checks of the native core build: +//! `{"cases": [...]}` renders prompts/lookups/wake chances for Python parity; +//! `{"scenario": {...}}` replays engine steps for native/WebAssembly parity. use serde::Deserialize; -use serde_json::json; +use serde_json::{Value, json}; use std::io::{self, Read}; -use tokenling_server::{types::WorldState, world}; +use tokenling_core::{ + engine::{Command, Engine}, + save, + types::WorldState, + world, +}; #[derive(Deserialize)] struct Case { st: WorldState, q: String, } #[derive(Deserialize)] -struct Input { - cases: Vec, +#[serde(rename_all = "camelCase")] +struct Scenario { + save: String, + t0: f64, + zone: String, + seed: String, + rolls: Vec, + steps: Vec, +} +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "camelCase")] +enum Step { + Command { + dt: f64, + command: Command, + }, + Line { + dt: f64, + text: String, + }, + /// Finishes the accepted catch game with this score. + Game { + dt: f64, + score: u32, + }, + Advance { + dt: f64, + returning: bool, + }, + Chat { + dt: f64, + kind: String, + replies: Vec<(String, String)>, + }, + /// `date`: absent for today, "missed" for the oldest unwritten day. + Diary { + dt: f64, + date: Option, + reply: String, + }, + Task { + dt: f64, + background: bool, + }, + Alerts { + dt: f64, + }, } 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|{ +fn cases(cases: Vec) -> Value { + let rows:Vec<_>=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)}}) - ); + 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)}}) +} +/// Every step's output and the whole engine afterwards. Random draws cycle +/// through `rolls`, so the WebAssembly replay draws the same values. +fn scenario(sc: Scenario) -> anyhow::Result { + let state = save::parse_save(&sc.save, sc.t0, &sc.zone)?; + let mut engine = Engine::new(state, sc.t0); + let mut next = 0; + let rolls = sc.rolls; + let mut dice = move || { + next += 1; + rolls[(next - 1) % rolls.len()] + }; + let (seed, mut t) = (sc.seed, sc.t0); + let mut out = vec![]; + for step in sc.steps { + let result = match step { + Step::Command { dt, command } => { + t += dt; + match engine.command(command, t, &seed, &mut dice) { + Ok(()) => Value::Null, + Err(e) => json!({ "error": e.0 }), + } + } + Step::Line { dt, text } => { + t += dt; + engine.commit_line(&text, t, &seed, &mut dice); + Value::Null + } + Step::Game { dt, score } => { + t += dt; + let game_id = engine + .game + .as_ref() + .map_or_else(String::new, |g| g.id.clone()); + let command = Command::GameComplete { game_id, score }; + match engine.command(command, t, &seed, &mut dice) { + Ok(()) => Value::Null, + Err(e) => json!({ "error": e.0 }), + } + } + Step::Advance { dt, returning } => { + t += dt; + engine.advance(t, &seed, returning, &mut dice); + Value::Null + } + Step::Chat { dt, kind, replies } => { + t += dt; + engine.begin_turn(t, &mut dice); + let mut turn = engine.chat_turn(&kind, t, &seed); + let mut requests = vec![]; + for (text, reason) in replies { + requests.push(turn.request()); + if !turn.feed(&text, &reason) { + break; + } + } + let body = turn.body().unwrap_or_default().to_owned(); + engine.commit_line(&body, t, &seed, &mut dice); + json!({ "requests": requests, "body": body }) + } + Step::Diary { dt, date, reply } => { + t += dt; + let date = match date.as_deref() { + Some("missed") => engine.state.history.keys().next().cloned(), + Some(date) => Some(date.to_owned()), + None => Some(engine.state.today.date.clone()), + } + .unwrap_or_default(); + match engine.diary_turn(&date, t, &seed, false) { + None => Value::Null, + Some(turn) => { + let page = turn.finish(&reply); + let committed = page.is_some(); + if let Some(page) = page { + engine.commit_page(page, false); + } + json!({ "request": turn.request(), "committed": committed }) + } + } + } + Step::Task { dt, background } => { + t += dt; + json!(engine.choose_task(t, background)) + } + Step::Alerts { dt } => { + t += dt; + json!(tokenling_core::body::alert_plan( + &engine.state, + t, + &mut dice + )) + } + }; + out.push(json!({ "out": result, "engine": engine })); + } + Ok(json!(out)) +} +fn main() -> anyhow::Result<()> { + let mut raw = String::new(); + io::stdin().read_to_string(&mut raw)?; + let mut input: Value = serde_json::from_str(&raw)?; + let output = if let Some(sc) = input.get_mut("scenario") { + scenario(serde_json::from_value(sc.take())?)? + } else { + cases(serde_json::from_value(input["cases"].take())?) + }; + println!("{output}"); Ok(()) } diff --git a/server/src/brain.rs b/server/src/brain.rs index b477350..2424abc 100644 --- a/server/src/brain.rs +++ b/server/src/brain.rs @@ -8,18 +8,10 @@ use ort::{ use serde::{Deserialize, Serialize}; use std::{path::Path, time::Instant}; use tokenizers::Tokenizer; +/// Requests, the stop rule and context fitting are shared with the browser's worker. +pub use tokenling_core::turn::{Generation, TOP_K, TOP_P}; +use tokenling_core::world; -#[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 { @@ -103,6 +95,8 @@ impl Brain { mode: "line".into(), max_new: 1, temperature: 0.0, + top_k: TOP_K, + top_p: TOP_P, ctx: 1024, })?; Ok(brain) @@ -115,29 +109,15 @@ impl Brain { .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) + world::fit( + &g.header, + &g.lines, + &g.prefix, + g.max_new, + g.ctx, + &mut |text| self.count(text).map_err(|e| e.to_string()), + ) + .map_err(anyhow::Error::msg) } pub fn generate(&mut self, g: &Generation) -> Result { self.generate_cancellable(g, || false) @@ -156,6 +136,10 @@ impl Brain { g.temperature.is_finite() && (0.0..=2.0).contains(&g.temperature), "invalid temperature" ); + ensure!( + (1..=50257).contains(&g.top_k) && g.top_p > 0.0 && g.top_p <= 1.0, + "invalid sampling settings" + ); let prompt = self.fit(g)?; let encoded = self .tokenizer @@ -247,7 +231,7 @@ impl Brain { self.session.run(input)? }; let (_, logits) = output["logits"].try_extract_tensor::()?; - let token = sample(&logits[logits.len() - 50257..], g.temperature)?; + let token = sample(&logits[logits.len() - 50257..], g)?; if step == 0 { prefill_ms = started.elapsed().as_secs_f64() * 1000.0; } @@ -260,23 +244,9 @@ impl Brain { .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"; + if let Some((why, kept)) = world::stop(&g.mode, &text) { + text = kept; + reason = why; break; } for name in &self.past_names { @@ -301,22 +271,8 @@ impl Brain { } } -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 { +fn sample(logits: &[f32], g: &Generation) -> Result { + let temperature = g.temperature; ensure!( logits.iter().all(|v| v.is_finite()), "non-finite model logits" @@ -330,8 +286,9 @@ fn sample(logits: &[f32], temperature: f32) -> Result { .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); + let k = g.top_k.clamp(1, ranked.len()); + ranked.select_nth_unstable_by(k - 1, |a, b| b.1.total_cmp(&a.1)); + ranked.truncate(k); ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1)); let high = ranked[0].1; let probabilities: Vec<_> = ranked @@ -344,7 +301,7 @@ fn sample(logits: &[f32], temperature: f32) -> Result { for p in &probabilities { kept += p; n += 1; - if kept / total >= 0.92 { + if kept / total >= g.top_p { break; } } diff --git a/server/src/host.rs b/server/src/host.rs index ffe61b2..5759838 100644 --- a/server/src/host.rs +++ b/server/src/host.rs @@ -1,8 +1,8 @@ //! Private capability API and background scheduler. The host owns all state. use crate::{ - brain::{Brain, Generated, Generation, lookup_tail}, + brain::{Brain, Generated, Generation}, routing::{self, Costs, Kind, Queue, Request as RouteRequest, Work}, - simulation::{self, Command, HostedPet}, + simulation::{self, Command, HostedPet, Task}, store::{AccessDenied, Conflict, Store}, types::{DiaryPage, PetState}, world, @@ -136,10 +136,10 @@ impl Host { } continue; } - let Some(task) = choose_task(&row, t) else { + let Some(task) = row.choose_task(t) else { continue; }; - let work = task.work(&row, t, &self.weather_seed); + let work = work(&task, &row, t, &self.weather_seed); let age = queue.age_ms(&id, row.epoch, row.context_revision, work, now); ready.push((id, row, task, RouteRequest { work, age_ms: age })); } @@ -195,10 +195,7 @@ impl Host { .lock() .unwrap() .commit(&id, row.epoch, row.context_revision, |r| { - r.pending = None; - r.last_turn = t; - r.schedule_idle(t); - r.revision += 1; + r.engine.begin_turn(t, &mut simulation::dice()); }); if !captured? { continue; @@ -255,27 +252,17 @@ impl Host { } 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.engine.commit_line( + &body, + at, + &host.weather_seed, + &mut simulation::dice(), + ); 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.engine.commit_page(page, false); current.last_error = None; - current.revision += 1; } Err(_) => { current.last_error = Some( @@ -283,7 +270,7 @@ impl Host { ); current.next_idle = at + if gpu_failed { 100.0 } else { 5000.0 }; if gpu_failed { - if let Task::Chat(kind) = &retry_task { + if let Task::Chat { kind } = &retry_task { current.pending = Some(kind.clone()); } } @@ -304,40 +291,34 @@ impl Host { } } -#[derive(Clone)] -enum Task { - Chat(String), - Diary(String), -} -impl Task { - fn work(&self, row: &HostedPet, t: f64, seed: &str) -> Work { - let (kind, bytes, cap) = match self { - Self::Chat(k) => ( - if k == "react" { - Kind::Reactive - } else { - Kind::Idle - }, - world::header("chat", &row.world(t, seed)).len() - + row.state.chat.iter().map(|s| s.len() + 1).sum::(), - if k == "react" { 956 } else { 468 }, - ), - Self::Diary(date) => { - let log = if date == &row.state.today.date { - Some(&row.state.today.log) - } else { - row.state.history.get(date) - }; - ( - Kind::Diary, - world::header("diary", &row.world(t, seed)).len() - + log.map_or(0, |d| d.log.iter().map(|s| s.len() + 1).sum::()), - 800, - ) - } - }; - Work::new(kind, bytes.div_ceil(4).min(cap)) - } +/// Approximate prompt size for routing: bounded UTF-8 bytes / 4, not tokens. +fn work(task: &Task, row: &HostedPet, t: f64, seed: &str) -> Work { + let (kind, bytes, cap) = match task { + Task::Chat { kind } => ( + if kind == "react" { + Kind::Reactive + } else { + Kind::Idle + }, + world::header("chat", &row.world(t, seed)).len() + + row.state.chat.iter().map(|s| s.len() + 1).sum::(), + if kind == "react" { 956 } else { 468 }, + ), + Task::Diary { date } => { + let log = if date == &row.state.today.date { + Some(&row.state.today.log) + } else { + row.state.history.get(date) + }; + ( + Kind::Diary, + world::header("diary", &row.world(t, seed)).len() + + log.map_or(0, |d| d.log.iter().map(|s| s.len() + 1).sum::()), + 800, + ) + } + }; + Work::new(kind, bytes.div_ceil(4).min(cap)) } enum Output { Chat(String), @@ -351,35 +332,8 @@ impl std::fmt::Display for FilteredDiary { } } impl std::error::Error for FilteredDiary {} -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 -} +/// Runs one task on a brain. The shared turn logic builds every request, +/// appends lookup results and filters the text; only generation happens here. fn execute( row: &HostedPet, task: Task, @@ -388,91 +342,26 @@ fn execute( 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))); + Task::Chat { kind } => { + let mut turn = row.chat_turn(&kind, t, seed); + loop { + let reply = engine.generate(&turn.request(), cancel)?; + if !turn.feed(&reply.text, &reply.reason) { + break; } } - Ok(Output::Chat(String::new())) + Ok(Output::Chat(turn.body().unwrap_or_default().to_owned())) } - 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) - }; - if text.is_empty() { - return Err(FilteredDiary.into()); - } - Ok(Output::Diary(DiaryPage { - date, - day: st.day, - weather: st.weather, - stage: st.stage, - text, - doodles, - })) + Task::Diary { date } => { + let turn = row + .diary_turn(&date, t, seed, false) + .context("diary day disappeared")?; + let reply = engine.generate(&turn.request(), cancel)?; + // Unsafe text drops the whole page; the day stays pending. + Ok(Output::Diary( + turn.finish(&reply.text).ok_or(FilteredDiary)?, + )) } } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 6c6cfcf..3a5bc12 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,5 +3,5 @@ pub mod host; pub mod routing; pub mod simulation; pub mod store; -pub mod types; -pub mod world; +/// The prompt contract and save types are shared with the browser. +pub use tokenling_core::{types, world}; diff --git a/server/src/simulation.rs b/server/src/simulation.rs index 306e603..d6187b2 100644 --- a/server/src/simulation.rs +++ b/server/src/simulation.rs @@ -1,830 +1,83 @@ -//! The pet's body and consequences of model tags; choices remain in the brain. -use crate::{types::*, world}; +//! A hosted pet: the shared engine (tokenling-core, the same rules the +//! browser runs locally) plus this host's ownership and error fields. use anyhow::{Result, ensure}; -use chrono::{Datelike, TimeZone, Timelike}; use serde::{Deserialize, Serialize}; +use std::ops::{Deref, DerefMut}; +use tokenling_core::types::PetState; +pub use tokenling_core::{ + body::{DAY, HOUR, MIN, simulate_body, weather_at}, + engine::{Command, Effect, Engine, Game, Task}, +}; -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() +/// The host draws from the thread's random generator. +pub fn dice() -> impl FnMut() -> f64 { + rand::random:: } -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, + /// Stored flattened, so rows keep the layout they had before the engine moved to core. + #[serde(flatten)] + pub engine: Engine, 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() +impl Deref for HostedPet { + type Target = Engine; + fn deref(&self) -> &Engine { + &self.engine + } } -fn pick(items: &[&str]) -> String { - items[(rand::random::() * items.len() as f64) as usize].into() +impl DerefMut for HostedPet { + fn deref_mut(&mut self) -> &mut Engine { + &mut self.engine + } } impl HostedPet { pub fn new(state: PetState, t: f64) -> Self { Self { - state, + engine: Engine::new(state, t), 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", ""); - } + /// Runs the body while this host owns the pet. 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, + if self.authority == "remote" { + self.engine.advance(t, seed, false, &mut dice()); } } 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; + self.engine.command(command, t, seed, &mut dice())?; 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 apply_line(&mut self, body: &str, t: f64, seed: &str) { + self.engine.apply_line(body, t, seed, &mut dice()); } 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; + self.engine.answer_offer(yes, t, seed, &mut dice()); } 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 - }; + self.engine.schedule_idle(t, &mut dice()); + } + /// The next task, after a failed turn's back-off unless the owner asked for one. + pub fn choose_task(&self, t: f64) -> Option { + if self.pending.is_none() && self.last_error.is_some() && t < self.next_idle { + return None; + } + self.engine.choose_task(t, true) } } diff --git a/server/src/world.rs b/server/src/world.rs deleted file mode 100644 index 6c54a48..0000000 --- a/server/src/world.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! 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 index 2a3a258..44d65b5 100644 --- a/server/tests/host.rs +++ b/server/tests/host.rs @@ -79,7 +79,7 @@ fn handoffs_fence_old_replies_and_keep_the_complete_save() { 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(&local.state.diary).unwrap(), serde_json::to_value(original.diary).unwrap() ); assert_eq!(db.get(&id, &key).unwrap().state.shelf, original.shelf); @@ -263,7 +263,7 @@ fn body_rates_and_wake_protection_match_the_browser() { 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")); + assert!(events.contains(&"fell asleep")); } struct Scripted { diff --git a/server/tests/native.rs b/server/tests/native.rs index b5c7040..ca28ae9 100644 --- a/server/tests/native.rs +++ b/server/tests/native.rs @@ -21,7 +21,7 @@ use std::{ time::Instant, }; use tokenling_server::{ - brain::{Brain, Generated, Generation}, + brain::{Brain, Generated, Generation, TOP_K, TOP_P}, host::{Generator, Host, Worker}, simulation::{self, HOUR, HostedPet, MIN}, store::Store, @@ -365,6 +365,8 @@ async fn real_brain_and_concurrent_private_http() { mode: "line".into(), max_new: 20, temperature: 0.0, + top_k: TOP_K, + top_p: TOP_P, ctx: 1024, }, Generation { @@ -374,6 +376,8 @@ async fn real_brain_and_concurrent_private_http() { mode: "line".into(), max_new: 20, temperature: 0.0, + top_k: TOP_K, + top_p: TOP_P, ctx: 1024, }, ]; diff --git a/server/tests/parity.mjs b/server/tests/parity.mjs index db4a39f..17f94e7 100644 --- a/server/tests/parity.mjs +++ b/server/tests/parity.mjs @@ -1,9 +1,12 @@ // Usage: node server/tests/parity.mjs [CONTRACT_BINARY] -// Build tokenling-contract first. No model or ONNX library is loaded. +// Build tokenling-contract and core/wasm/build.sh first. No model or ONNX +// library is loaded. Checks the native core against train/world.py, then +// replays one engine scenario natively and in WebAssembly, in two time zones. import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import * as C from "../../web/core.ts"; const root = fileURLToPath(new URL("../../", import.meta.url)); const binary = process.argv[2] || root + "server/target/debug/tokenling-contract"; if (!existsSync(binary)) @@ -26,3 +29,146 @@ for (let i = 0; i < expected.cases.length; i++) { console.log( `${expected.cases.length} Rust/Python states, ${Object.keys(expected.lists).length} lists, 0 mismatches`, ); + +C.initCoreSync(readFileSync(root + "core/wasm/pkg/tokenling_core_bg.wasm")); +const MIN = 60e3, + HOUR = 60 * MIN; +// A day with chat, lookups, offers, a game, an absence, sleep, dreams, a +// missed diary page and growth. The second zone crosses a DST change. +function scenario(zone, t0) { + const save = JSON.parse(readFileSync(root + "test/fixtures/save-v1.json", "utf8")); + Object.assign(save, { created: t0 - 12 * HOUR, lastTick: t0, nextPoop: t0 + 2 * HOUR }); + save.pet.hatched = t0 - 11 * HOUR; + const care = (kind, item = null) => ({ type: "care", kind, item }); + return { + save: JSON.stringify(save), + t0, + zone, + seed: "parity", + rolls: [0.12, 0.83, 0.47, 0.05, 0.66, 0.91, 0.3, 0.58], + steps: [ + { op: "advance", dt: 1000, returning: false }, + { op: "command", dt: 1000, command: { type: "message", text: "hello friend, frogs?" } }, + { op: "task", dt: 0, background: false }, + { + op: "chat", + dt: 500, + kind: "react", + replies: [ + [" hi [recall:frogs]", "lookup"], + [" i [look]", "lookup"], + [" [clock]", "lookup"], + [" yay [remember:Sam likes frogs] [gift:flower] [emote:love]", "line"], + ], + }, + { op: "command", dt: 1000, command: care("feed", "cookie") }, + { op: "command", dt: 10, command: care("bath") }, + { op: "line", dt: 2000, text: "nom [yes] [walk:left]" }, + { op: "command", dt: 1000, command: care("play") }, + { op: "line", dt: 1000, text: "[yes] [emote:happy]" }, + { op: "game", dt: 20000, score: 5 }, + { op: "advance", dt: 3 * HOUR, returning: true }, + { op: "command", dt: 1000, command: care("clean") }, + { op: "command", dt: 1000, command: care("pat") }, + { op: "command", dt: 1000, command: care("pat") }, + { op: "command", dt: 1000, command: { type: "message", text: "goodnight little one" } }, + { + op: "chat", + dt: 1000, + kind: "idle", + replies: [ + [" fuck", "line"], + [" sleepy [sleep] [diary:a big day]", "line"], + ], + }, + { op: "line", dt: 1000, text: "[dream:moon] mm" }, + { op: "task", dt: 2000, background: true }, + { + op: "diary", + dt: 1000, + reply: " it was fun [doodle:heart] [doodle:spaceship]\n\nwe played. [emote:happy]", + }, + { op: "command", dt: 1000, command: care("lights") }, + { op: "advance", dt: 10 * HOUR, returning: false }, + { op: "task", dt: 1000, background: true }, + { op: "alerts", dt: 1000 }, + { op: "command", dt: 1000, command: { type: "forgetMemory", index: 0, fact: "wrong" } }, + { op: "command", dt: 1000, command: { type: "rename", name: "S[a]m:" } }, + { op: "command", dt: 1000, command: { type: "chatter", value: false } }, + { op: "line", dt: 1000, text: "[gift:star] [want:snack] [diary:new day]" }, + { op: "task", dt: 31000, background: true }, + { op: "advance", dt: 3 * 24 * HOUR, returning: true }, + { op: "task", dt: 31000, background: true }, + { op: "diary", dt: 1000, date: "missed", reply: " i missed sam.\nthe end [doodle:moon]" }, + ], + }; +} +function replay(sc) { + const parsed = C.parseSave(sc.save, sc.t0, sc.zone); + assert.ok("state" in parsed, parsed.error); + const e = new C.Engine(parsed.state, sc.t0, sc.seed); + let next = 0; + e.setRolls(() => sc.rolls[next++ % sc.rolls.length]); + let t = sc.t0; + const attempt = (fn) => { + try { + fn(); + return null; + } catch (error) { + return { error: error.message }; + } + }; + const out = []; + for (const step of sc.steps) { + t += step.dt; + let result = null; + if (step.op === "command") result = attempt(() => e.command(step.command, t)); + else if (step.op === "line") e.commitLine(step.text, t); + else if (step.op === "game") + result = attempt(() => + e.command({ type: "gameComplete", gameId: e.game?.id || "", score: step.score }, t), + ); + else if (step.op === "advance") e.advance(t, step.returning); + else if (step.op === "chat") { + e.beginTurn(t); + const turn = e.chatTurn(step.kind, t), + requests = []; + for (const [text, reason] of step.replies) { + requests.push(turn.request()); + if (!turn.feed(text, reason)) break; + } + const body = turn.body() ?? ""; + e.commitLine(body, t); + result = { requests, body }; + } else if (step.op === "diary") { + const state = e.state(); + const date = + step.date === "missed" ? Object.keys(state.history).sort()[0] || "" : state.today.date; + const turn = e.diaryTurn(date, t); + if (turn) { + const request = turn.request(); + result = { request, committed: e.commitDiary(turn, step.reply) }; + } + } else if (step.op === "task") result = e.chooseTask(t, step.background); + else if (step.op === "alerts") result = e.alertPlan(t); + out.push({ out: result, engine: JSON.parse(e.engineJson()) }); + } + return out; +} +for (const [zone, start] of [ + ["UTC", "2026-10-01T18:00:00Z"], + ["America/Los_Angeles", "2026-11-01T01:00:00Z"], +]) { + process.env.TZ = zone; // the browser build reads the device's clock + const sc = scenario(zone, Date.parse(start)); + const native = JSON.parse( + execFileSync(binary, { input: JSON.stringify({ scenario: sc }), maxBuffer: 1 << 26 }), + ); + const wasm = replay(sc); + assert.equal(wasm.length, native.length); + for (let i = 0; i < native.length; i++) + assert.deepEqual(wasm[i], native[i], `${zone} step ${i} (${sc.steps[i].op})`); + const last = native.at(-1).engine.state; + assert.ok(last.diary.length === 2 && last.pet.stage === "teen", `${zone}: scenario ran`); + console.log(`${zone}: ${native.length} engine steps identical in native and WebAssembly builds`); +} diff --git a/site/build.sh b/site/build.sh index 664bdec..eedb9d5 100755 --- a/site/build.sh +++ b/site/build.sh @@ -31,16 +31,18 @@ RELEASE=${RELEASE:-$HERE/release.json} BASE=${BASE:-/} case "$BASE" in /*/|/) ;; *) echo "BASE must start and end with /" >&2; exit 2;; esac [ -f "$RELEASE" ] || { echo "no release file: $RELEASE" >&2; exit 2; } -# Type-check and bundle before replacing output. Hash emitted bytes, including -# the bundled Preact dependency, so a browser never mixes app/worker versions. +# Build the shared rules for the browser, then type-check and bundle before +# replacing output. Hash emitted bytes, including the bundled Preact +# dependency and the rules module, so a browser never mixes app/worker/rules versions. [ -x "$REPO/node_modules/.bin/tsc" ] || { echo "Run npm ci before building the site" >&2; exit 2; } +"$REPO/core/wasm/build.sh" (cd "$REPO" && npm run typecheck) BUNDLE=$(mktemp -d) STAGE= trap 'rm -rf "$BUNDLE"; [ -z "$STAGE" ] || rm -rf "$STAGE"' EXIT trap 'exit 1' HUP INT TERM node "$HERE/bundle.mjs" "$BUNDLE" -VER=$(cat "$BUNDLE"/*.js "$BUNDLE"/*.map | sha256sum | cut -c1-12) +VER=$(cat "$BUNDLE"/*.js "$BUNDLE"/*.map "$BUNDLE"/*.wasm | sha256sum | cut -c1-12) mkdir -p "$(dirname "$OUT")" STAGE=$(mktemp -d "$(dirname "$OUT")/.$(basename "$OUT").partial.XXXXXX") mkdir -p "$STAGE/app/$VER" "$STAGE/assets" diff --git a/site/bundle.mjs b/site/bundle.mjs index 889a1ce..1d4c42c 100644 --- a/site/bundle.mjs +++ b/site/bundle.mjs @@ -1,10 +1,14 @@ -// Bundle the typed Preact app and its standalone inference worker. +// Bundle the typed Preact app and its standalone inference worker, and copy +// the shared rules module (core/wasm/pkg, built by core/wasm/build.sh) beside them. // Usage: node site/bundle.mjs OUT_DIR. No environment variables are read. import { build } from "esbuild"; +import { copyFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; const root = fileURLToPath(new URL("..", import.meta.url)); if (process.argv.length !== 3) throw new Error("Usage: node site/bundle.mjs OUT_DIR"); +const core = resolve(root, "core/wasm/pkg/tokenling_core_bg.wasm"); +if (!existsSync(core)) throw new Error("Build the shared rules first: core/wasm/build.sh"); await build({ absWorkingDir: root, entryPoints: { main: "web/main.tsx", brain: "web/brain.ts" }, @@ -16,6 +20,8 @@ await build({ minify: true, sourcemap: true, legalComments: "eof", - // No split chunks: the offline resource manifest is main.js plus brain.js. + // No split chunks: the offline resource manifest is main.js, brain.js and the rules module. splitting: false, }); +// Both bundles load the module from beside themselves (web/core.ts). +copyFileSync(core, resolve(process.argv[2], "tokenling_core_bg.wasm")); diff --git a/test/body.test.mjs b/test/body.test.mjs index 9a721aa..eea2683 100644 --- a/test/body.test.mjs +++ b/test/body.test.mjs @@ -1,11 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { fresh } from "../web/app/save-format.ts"; -import { simulateBody } from "../web/app/body.ts"; -import { HOUR, MIN } from "../web/app/utils.ts"; +import { readFileSync } from "node:fs"; +import * as C from "../web/core.ts"; +C.initCoreSync(readFileSync(new URL("../core/wasm/pkg/tokenling_core_bg.wasm", import.meta.url))); +const MIN = 60e3, + HOUR = 60 * MIN; const noon = new Date(2026, 9, 1, 12).getTime(); function pet() { - const state = fresh(); + const state = C.freshSave(noon, "UTC"); + state.owner = "Sam"; state.pet = { name: "Mochi", traits: ["sweet", "curious"], @@ -22,41 +25,128 @@ test("awake and sleeping body rates remain unchanged", () => { sleeping = pet(); sleeping.asleep = true; sleeping.stats.energy = 40; - simulateBody(awake, noon, noon + HOUR, false); - simulateBody(sleeping, noon, noon + HOUR, false); - assert.deepEqual(awake.stats, { tummy: 62, fun: 64, energy: 75, clean: 87 }); - assert.deepEqual(sleeping.stats, { tummy: 68, fun: 70, energy: 62, clean: 89.5 }); + assert.deepEqual(C.simulateBody(awake, noon, noon + HOUR, false).state.stats, { + tummy: 62, + fun: 64, + energy: 75, + clean: 87, + }); + assert.deepEqual(C.simulateBody(sleeping, noon, noon + HOUR, false).state.stats, { + tummy: 68, + fun: 70, + energy: 62, + clean: 89.5, + }); }); test("sleep pauses grime exposure; awake exposure resumes it", () => { - const state = pet(); + let state = pet(); state.poop = 3; state.grimeSince = noon - 45 * MIN; state.asleep = true; state.stats.energy = 10; - simulateBody(state, noon, noon + 20 * MIN, true); + state = C.simulateBody(state, noon, noon + 20 * MIN, true).state; assert.equal(state.sick, false); assert.equal(state.grimeSince, noon - 25 * MIN); state.asleep = false; - simulateBody(state, noon + 20 * MIN, noon + 40 * MIN, true); - assert.equal(state.sick, true); + assert.equal(C.simulateBody(state, noon + 20 * MIN, noon + 40 * MIN, true).state.sick, true); }); test("exhaustion and recent wake protection are body rules", () => { - const state = pet(); + let state = pet(); state.stats.energy = 5; state.wokeAt = noon; - simulateBody(state, noon, noon + MIN, false); + state = C.simulateBody(state, noon, noon + MIN, false).state; assert.equal(state.asleep, false); - const events = simulateBody(state, noon + MIN, noon + 6 * MIN, false); - assert.equal(state.asleep, true); + const { state: after, events } = C.simulateBody(state, noon + MIN, noon + 6 * MIN, false); + assert.equal(after.asleep, true); assert.ok(events.includes("fell asleep")); }); -test("a rested pet wakes, and a projection cannot mutate the saved body", () => { +test("a rested pet wakes; the projection never changes its input", () => { const state = pet(); state.asleep = true; state.stats.energy = 98; - const projection = structuredClone(state); - assert.ok(simulateBody(projection, noon, noon + 10 * MIN, false).includes("woke")); - assert.equal(projection.asleep, false); + const { state: after, events } = C.simulateBody(state, noon, noon + 10 * MIN, false); + assert.ok(events.includes("woke")); + assert.equal(after.asleep, false); assert.equal(state.asleep, true); assert.equal(state.stats.energy, 98); }); + +function engine(state = pet(), at = noon) { + state.lastTick = at; + return new C.Engine(state, at, ""); +} +test("care offers are brain decisions; only one plain answer applies them", () => { + const e = engine(); + e.command({ type: "care", kind: "feed", item: "cookie" }, noon); + assert.equal(e.state().chat.at(-1), "* Sam offered Mochi a cookie *"); + assert.throws( + () => e.command({ type: "care", kind: "bath", item: null }, noon), + /still deciding about a cookie/, + ); + const tummy = e.state().stats.tummy; + e.commitLine("[yes] [no] hmm", noon); + assert.equal(e.state().offer, null, "an ambiguous answer counts as no"); + assert.equal(e.state().stats.tummy, tummy); + assert.ok(e.state().today.log.includes("said no to a cookie")); + e.command({ type: "care", kind: "feed", item: "apple" }, noon); + e.commitLine("yum [yes] [emote:happy]", noon); + assert.equal(e.state().stats.tummy, tummy + 28); + assert.deepEqual( + e.takeEffects().map((x) => x.kind), + ["eat", "emote"], + ); + assert.throws(() => e.command({ type: "care", kind: "clean", item: null }, noon), /All clean/); +}); +test("a sleeping pet only dreams or answers an offer, and pats may wake it", () => { + const state = pet(); + state.asleep = true; + state.stats.energy = 50; + const e = engine(state); + e.command({ type: "care", kind: "feed", item: "cookie" }, noon); + e.commitLine("[dream:cookie] [no] [gift:flower]", noon); + assert.equal(e.state().asleep, true); + assert.deepEqual(e.state().shelf, []); + assert.ok(e.state().today.log.includes("dreamed of a cookie")); + e.setWakeRoll(() => 0.99); + e.command({ type: "care", kind: "pat", item: null }, noon); + assert.equal(e.state().asleep, true); + e.setWakeRoll(() => 0); + e.command({ type: "care", kind: "pat", item: null }, noon + 1000); + assert.equal(e.state().asleep, false); + assert.equal(e.state().chat.at(-2), "* Mochi woke up *"); +}); +test("turns: reactions first, lookups continue the line, stale replies are dropped", () => { + const e = engine(); + e.command({ type: "message", text: "what time is it?" }, noon); + assert.deepEqual(e.chooseTask(noon, false), { type: "chat", kind: "react" }); + e.beginTurn(noon); + assert.equal(e.chooseTask(noon, false), null, "a hidden page does not idle"); + const turn = e.chatTurn("react", noon); + assert.equal(turn.request().prefix, "Mochi:"); + assert.equal(turn.feed(" it is [clock]", "lookup"), true); + assert.match(turn.request().prefix, /^Mochi: it is \[clock\]\{\d\d:\d\d \w+\}$/); + assert.equal(turn.feed(" yay", "line"), false); + e.commitLine(turn.body(), noon); + assert.match(e.state().chat.at(-1), /^Mochi: it is \[clock\]\{.*\} yay$/); + // A reply generated before the owner spoke again belongs to an older context. + const seen = e.contextRevision; + e.command({ type: "message", text: "hello?" }, noon + 1000); + assert.notEqual(e.contextRevision, seen); + assert.equal(e.pending, "react"); +}); +test("silent lines stay at two in a row", () => { + const e = engine(); + for (const line of ["[walk:left]", "[emote:spin]", "[walk:right]"]) e.commitLine(line, noon); + assert.deepEqual( + e.state().chat.slice(-2), + ["Mochi: [emote:spin]", "Mochi: [walk:right]"].slice(-2), + ); + assert.equal(e.state().chat.filter((l) => l.startsWith("Mochi:")).length, 2); +}); +test("coming back after a long absence is noticed once", () => { + const e = engine(); + e.advance(noon + 3 * HOUR, true); + assert.equal(e.state().chat.at(-1), "* Sam came back after 3 hours *"); + assert.ok(e.state().today.log.includes("Sam was away for a while")); + assert.equal(e.pending, "react"); +}); diff --git a/test/browser-flow.js b/test/browser-flow.js index 2cd971d..857d09d 100644 --- a/test/browser-flow.js +++ b/test/browser-flow.js @@ -50,10 +50,12 @@ async function browserFlow(page) { .then((t) => t.includes("hello little friend")), "Missing owner message", ); - await page.evaluate(() => { - tokenling.state().stats.tummy = 15; - tokenling.renderAll(); - }); + // The engine owns the save; debug edits go through it. + await page.evaluate(() => + tokenling.debug.edit((s) => { + s.stats.tummy = 15; + }), + ); await page.click("#act-feed"); await page.getByRole("button", { name: "cookie", exact: true }).click(); await page.waitForFunction(() => !tokenling.state().offer && !tokenling.brain.busy, null, { @@ -105,9 +107,10 @@ async function browserFlow(page) { result.push("memory, shelf and real diary generation"); await page.evaluate(() => { - const s = tokenling.state(); - s.asleep = false; - s.offer = { kind: "play", item: null }; + tokenling.debug.edit((s) => { + s.asleep = false; + s.offer = { kind: "play", item: null }; + }); tokenling.debug.applyTags("[yes]"); }); await page.waitForFunction(() => !!tokenling.view.game); diff --git a/test/hosting-flow.js b/test/hosting-flow.js index fe063d3..a4401c5 100644 --- a/test/hosting-flow.js +++ b/test/hosting-flow.js @@ -115,7 +115,10 @@ async function hostingFlow(page) { "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. + // Explicit fixture on the local recovery branch, edited through the engine. + tokenling.debug.edit((s) => { + s.memories.push("Sam likes frogs"); + }); return tokenling.state().pet.hatched; }); const alternate = await page.evaluate((base) => { diff --git a/test/parity.mjs b/test/parity.mjs index e27300b..fd249f7 100644 --- a/test/parity.mjs +++ b/test/parity.mjs @@ -1,35 +1,41 @@ -// Checks web/world.ts renders prompts byte-identically to train/world.py. +// Checks the shared rules (core/, as the browser's WebAssembly build) render +// prompts byte-identically to train/world.py. Run core/wasm/build.sh first. import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import * as W from "../web/world.ts"; +import * as C from "../web/core.ts"; const here = fileURLToPath(new URL(".", import.meta.url)); +C.initCoreSync(readFileSync(new URL("../core/wasm/pkg/tokenling_core_bg.wasm", import.meta.url))); const { cases, lists } = JSON.parse(execFileSync("python3", [here + "parity_states.py", "1"], { maxBuffer: 1 << 26 })); let bad = 0; for (const [k, py] of Object.entries(lists)) { - const js = W[k] instanceof Set ? [...W[k]].sort() : W[k]; - if (JSON.stringify(js) !== JSON.stringify(py)) { bad++; console.log(k, JSON.stringify(js), "!=", JSON.stringify(py)); } + if (JSON.stringify(C.L[k]) !== JSON.stringify(py)) { bad++; console.log(k, JSON.stringify(C.L[k]), "!=", JSON.stringify(py)); } } // Every app event line must match exactly one Python pattern (the training // validator), and offers must be recognised as offers. -const E = W.events; +const E = (name, o, p, arg) => C.event(name, o, p, arg); for (const c of cases) { - const wake = ["talk", "touch", "light"].flatMap((k) => Array.from({ length: 6 }, (_, n) => W.wakeChance(c.st.stats.energy, k, n + 1))); + const wake = ["talk", "touch", "light"].flatMap((k) => Array.from({ length: 6 }, (_, n) => C.wakeChance(c.st.stats.energy, k, n + 1))); if (wake.some((v, i) => Math.abs(v - c.wake[i]) > 1e-12)) { bad++; console.log("wake chance mismatch"); } - for (const [k, got] of [["chat", W.header("chat", c.st)], ["diary", W.header("diary", c.st)], - ["look", W.look(c.st)], ["clock", W.clock(c.st)], ["recall", W.recall(c.st.memories, c.q)]]) { + for (const [k, got] of [["chat", C.header("chat", c.st)], ["diary", C.header("diary", c.st)], + ["look", C.toolResult(c.st, "look")], ["clock", C.toolResult(c.st, "clock")], ["recall", C.recall(c.st.memories, c.q)], + ["recall", C.toolResult(c.st, "recall", c.q)]]) { if (got !== c[k]) { bad++; if (bad < 4) console.log(k, JSON.stringify(got), "!=", JSON.stringify(c[k])); } } const o = c.st.owner, p = c.st.pet; const evs = c.events.map((r) => new RegExp("^" + r + "$")), offers = c.offers.map((r) => new RegExp("^" + r + "$")); - const lines = [...W.FOODS.map((f) => [E.offerFood(o, p, f), true]), [E.offerBath(o, p), true], [E.offerMedicine(o, p), true], - [E.offerCatch(o, p), true], [E.caught(o, p, 1), false], [E.caught(o, p, 7), false], [E.pat(o, p), false], - [E.lights(o, true), false], [E.lights(o, false), false], [E.woke(p), false], [E.pooped(p), false], [E.cleaned(o), false], - [E.back(o, "3 hours"), false], [E.back(o, "1 day"), false], ...["kid", "teen", "adult"].map((s) => [E.grew(p, s), false])]; + const lines = [...C.L.FOODS.map((f) => [E("offerFood", o, p, f), true]), [E("offerBath", o, p), true], [E("offerMedicine", o, p), true], + [E("offerCatch", o, p), true], [E("caught", o, p, "1"), false], [E("caught", o, p, "7"), false], [E("pat", o, p), false], + [E("lights", o, p, "on"), false], [E("lights", o, p, "off"), false], [E("woke", o, p), false], [E("pooped", o, p), false], [E("cleaned", o, p), false], + [E("back", o, p, "3 hours"), false], [E("back", o, p, "1 day"), false], ...["kid", "teen", "adult"].map((s) => [E("grew", o, p, s), false])]; for (const [line, isOffer] of lines) { if (evs.filter((r) => r.test(line)).length !== 1 || offers.some((r) => r.test(line)) !== isOffer) { bad++; if (bad < 8) console.log("event", JSON.stringify(line), isOffer ? "(offer)" : ""); } } } +// The parity fixture names the lists Python has; the core also lists body colours. +const extra = Object.keys(C.L).filter((k) => !(k in lists)); +if (JSON.stringify(extra) !== JSON.stringify(["COLORS"])) { bad++; console.log("unexpected lists", extra); } console.log(`${cases.length} states, ${Object.keys(lists).length} word lists, ${bad} mismatches`); process.exit(bad ? 1 : 0); diff --git a/test/storage.test.mjs b/test/storage.test.mjs index 61e6d19..a15e3d6 100644 --- a/test/storage.test.mjs +++ b/test/storage.test.mjs @@ -1,14 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { initCoreSync } from "../web/core.ts"; +import { startEngine } from "../web/app/engine.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"; +initCoreSync(readFileSync(new URL("../core/wasm/pkg/tokenling_core_bg.wasm", import.meta.url))); const raw = readFileSync(new URL("fixtures/save-v1.json", import.meta.url), "utf8"); const original = JSON.parse(raw); test("the previous browser save migrates to local mode without losing pet data", () => { - assert.equal(VERSION, 2); + assert.equal(VERSION(), 2); const loaded = parseSave(raw); assert.deepEqual(loaded, { ...original, v: 2, hosting: fresh().hosting }); const { hosting, ...preserved } = loaded; @@ -40,13 +43,13 @@ test("bad saves, reset and loss of ownership block debounced writes", async () = const untouched = entries.get(KEY); store.tabOwned = true; store.resetting = false; - store.state = load(); + startEngine(load(), ""); save(); await new Promise((resolve) => setTimeout(resolve, 240)); assert.equal(store.badEgg.newer, true); assert.equal(entries.get(KEY), untouched); store.badEgg = null; - store.state = parseSave(raw); + startEngine(parseSave(raw), ""); save(); store.tabOwned = false; await new Promise((resolve) => setTimeout(resolve, 240)); diff --git a/test/wasm-core-proof/README.md b/test/wasm-core-proof/README.md deleted file mode 100644 index c620c37..0000000 --- a/test/wasm-core-proof/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Rust/WASM rules feasibility proof - -This opt-in proof compiles the **existing** `server/src/world.rs` and its -types for JavaScript. It exports prompt rendering, lookups and a parity adapter -through wasm-bindgen/serde-wasm-bindgen. The application does not load it and -the server's native build is unchanged. No model, save or network access is -part of the module. It is a build/interop experiment, not the shared-core migration. - -Use Rust 1.88+ with `wasm32-unknown-unknown`, Node.js 24+ and wasm-bindgen-cli -**0.2.129**, matching the crate exactly. Keep generated packages outside Git -(the local `target/` and `pkg/` directories are ignored). - -```sh -rustup target add wasm32-unknown-unknown -cargo install wasm-bindgen-cli --version 0.2.129 --locked -cargo build --release --locked --manifest-path test/wasm-core-proof/Cargo.toml --target wasm32-unknown-unknown -wasm-bindgen test/wasm-core-proof/target/wasm32-unknown-unknown/release/tokenling_wasm_proof.wasm --target nodejs --out-dir test/wasm-core-proof/pkg/node -python3 -c 'from pathlib import Path; Path("test/wasm-core-proof/pkg/node/package.json").write_text("{\"type\":\"commonjs\"}\n")' -node test/wasm-core-proof/check.mjs test/wasm-core-proof/pkg/node/tokenling_wasm_proof.js -wasm-bindgen test/wasm-core-proof/target/wasm32-unknown-unknown/release/tokenling_wasm_proof.wasm --target web --out-dir test/wasm-core-proof/pkg/web -``` - -The Node check reuses the project's Python fixture generator: 300 cases and -15 closed lists, plus headers, clock/look/recall and wake probabilities. It -also calls the exported JS header/lookup functions individually, and reports -module-load and header-batch times as diagnostics. Measurements are desktop -Node timings and do not establish phone/browser speed. -The package marker is needed because this repository uses `type: module`, -while wasm-bindgen's Node target generates CommonJS. - -For a web smoke check, serve the generated web package with any local static -server and import its ES module. Call its default `init()` before exported -functions. The structured state bridge supports JavaScript objects and arrays; -`json_compatible()` preserves plain-object output and JSON numeric conventions. -Keep a typed TS wrapper around `JsValue` exports for application type checking. - -The included `browser.html` runs the same parity in a browser: copy it to the -generated web package as `index.html` and write the output of -`python3 test/parity_states.py 1` beside it as `cases.json`. Serve that package -locally and read `window.proof`; the page contains only synthetic test fixtures. - -## Functional receipt (2026-10-09) - -Rust 1.98.1/wasm-bindgen 0.2.129/serde-wasm-bindgen 0.6.5 built the existing -Rust sources successfully. Node.js 24 and headless desktop Chromium each -passed 300 states/15 lists with zero mismatches, including individual exported -header/lookup calls and wake probabilities. No app integration was changed. - -The generated WASM was 1,127,760 bytes, 345,732 bytes with gzip; its JS glue -was 19,010 bytes, 4,455 bytes with gzip. Node initialization was about 8 ms; -one desktop browser load was about 15 ms. Node's five 300-header batches -took 4.5–6.1 ms each. These are single-desktop diagnostics with no phone, -download, memory or optimized-asset study, not runtime performance promises. - -An application migration needs a separate rules crate, UI build/hash/offline -integration and initialization/error recovery. Move prompt/lookups first, then -body/save rules with explicit clock/randomness inputs. Keep Preact/canvas, -browser storage and inference outside the rules crate. Python training parity -remains an independent contract check. See [the design option](../../docs/architecture.md#shared-nativebrowser-rules-design-option). diff --git a/test/wasm-core-proof/browser.html b/test/wasm-core-proof/browser.html deleted file mode 100644 index 4b4bca5..0000000 --- a/test/wasm-core-proof/browser.html +++ /dev/null @@ -1,16 +0,0 @@ -Rust rules WASM proof
loading
\ No newline at end of file diff --git a/test/wasm-core-proof/check.mjs b/test/wasm-core-proof/check.mjs deleted file mode 100644 index d08bf23..0000000 --- a/test/wasm-core-proof/check.mjs +++ /dev/null @@ -1,50 +0,0 @@ -// node test/wasm-core-proof/check.mjs NODE_BINDINGS_FILE -// The bindings file is generated by wasm-bindgen --target nodejs. No model, -// private save or application storage is read or written by this proof. -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { createRequire } from "node:module"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { performance } from "node:perf_hooks"; -if (process.argv.length !== 3) throw new Error("Supply the generated Node bindings file"); -const expected = JSON.parse( - execFileSync("python3", [fileURLToPath(new URL("../parity_states.py", import.meta.url)), "1"], { - maxBuffer: 1 << 26, - }), -); -const started = performance.now(); -const core = createRequire(import.meta.url)(resolve(process.argv[2])); -const loadedMs = performance.now() - started; -const actual = core.parity(expected); -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)); - assert.equal(core.header("chat", expected.cases[i].st), expected.cases[i].chat); - assert.equal( - core.tool_result(expected.cases[i].st, "recall", expected.cases[i].q), - expected.cases[i].recall, - ); -} -const trials = []; -for (let round = 0; round < 5; round++) { - const begin = performance.now(); - for (const c of expected.cases) core.header("chat", c.st); - trials.push(performance.now() - begin); -} -process.stdout.write( - JSON.stringify( - { - states: expected.cases.length, - lists: Object.keys(expected.lists).length, - mismatches: 0, - loadedMs, - headersPerTrial: 300, - headerBatchMs: trials, - }, - null, - 2, - ) + "\n", -); diff --git a/test/wasm-core-proof/src/lib.rs b/test/wasm-core-proof/src/lib.rs deleted file mode 100644 index 415d944..0000000 --- a/test/wasm-core-proof/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Isolated feasibility proof: existing Rust world code, callable from JS. -//! The application does not import this module. No model, network or storage. -#[path = "../../../server/src/types.rs"] -#[allow(dead_code)] -mod types; -#[path = "../../../server/src/world.rs"] -#[allow(dead_code)] -mod world; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use wasm_bindgen::prelude::*; -#[derive(Deserialize)] -struct Case { - st: types::WorldState, - q: String, -} -#[derive(Deserialize)] -struct Input { - cases: Vec, -} -fn sorted(values: &[&str]) -> Vec { - let mut result: Vec<_> = values.iter().map(|v| v.to_string()).collect(); - result.sort(); - result -} -#[wasm_bindgen] -pub fn header(kind: &str, state: JsValue) -> Result { - if !["chat", "diary"].contains(&kind) { - return Err(JsError::new("unknown prompt kind")); - } - let state: types::WorldState = serde_wasm_bindgen::from_value(state)?; - Ok(world::header(kind, &state)) -} -#[wasm_bindgen] -pub fn tool_result(state: JsValue, name: &str, arg: &str) -> Result { - let state: types::WorldState = serde_wasm_bindgen::from_value(state)?; - Ok(world::tool_result(&state, name, arg) - .serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) -} -#[wasm_bindgen] -pub fn parity(input: JsValue) -> Result { - let input: Input = serde_wasm_bindgen::from_value(input)?; - let cases:Vec<_>=input.cases.iter().map(|c| { - let wake:Vec<_>=["talk","touch","light"].iter().flat_map(|kind|(1..=6).map(move|n|world::wake_chance(c.st.stats.energy,kind,n))).collect(); - json!({"chat":world::header("chat",&c.st),"diary":world::header("diary",&c.st),"clock":world::tool_result(&c.st,"clock",""),"look":world::tool_result(&c.st,"look",""),"recall":world::tool_result(&c.st,"recall",&c.q),"wake":wake}) - }).collect(); - let output = json!({"cases":cases,"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(output.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) -} diff --git a/train/world.py b/train/world.py index e5449ea..b7238ce 100644 --- a/train/world.py +++ b/train/world.py @@ -1,6 +1,6 @@ """Shared tokenling world model: state headers, event strings, tool results. -The browser app (web/world.ts) builds exactly the same strings. Keep the two in +The shared rules (core/src/world.rs) build exactly the same strings. Keep the two in sync; the fine-tuned model only understands what it saw in training. """ import random @@ -57,7 +57,7 @@ def level_words(s): def header(kind, st): - """kind: 'chat' or 'diary'. Must match web/world.ts header().""" + """kind: 'chat' or 'diary'. Must match header() in core/src/world.rs.""" w = level_words(st["stats"]) lines = [f"## tokenling {kind}", f"pet: {st['pet']} · {st['stage']} · day {st['day']} · {', '.join(st['traits'])}", @@ -129,7 +129,7 @@ def tool_result(st, name, arg): # Event lines inserted into chat by the app. Python regexes accept exactly the -# formats web/world.ts emits. Offers ask the pet to decide: its next line must +# formats core/src/world.rs emits. Offers ask the pet to decide: its next line must # answer with [yes] or [no]. def event_patterns(owner, pet): o, p = re.escape(owner), re.escape(pet) diff --git a/web/app/animation.ts b/web/app/animation.ts index e57ee54..8edb706 100644 --- a/web/app/animation.ts +++ b/web/app/animation.ts @@ -1,10 +1,7 @@ +import { L, type Effect } from "../core.ts"; import { FLOOR, W as LW } from "../scene.ts"; -import type { Disturbance } from "../types.ts"; -import * as Wd from "../world.ts"; -import { MIN, WANT_ICON } from "./config.ts"; -import { pushLine } from "./conversation.ts"; -import { logToday, rollDay } from "./simulation.ts"; -import { store, view } from "./store.ts"; +import { WANT_ICON } from "./config.ts"; +import { store, toast, view } from "./store.ts"; import { clamp, rand } from "./utils.ts"; // Animations queue, so "[emote:happy] [emote:dance]" plays both in order. @@ -49,51 +46,60 @@ export function want(kind: string, ms = 25e3) { view.want = WANT_ICON[kind]; view.wantUntil = performance.now() + ms; } -// Disturbances are body rolls. Offers wake only when the brain accepts. -// This short-lived counter resets on waking, a new sleep, reload, or 5 min quiet. -export const disturbances = { count: 0, last: 0 }; -let wakeRoll = Math.random; -export function resetDisturbances() { - disturbances.count = 0; - disturbances.last = 0; -} -export function disturbanceCount(now = Date.now()) { - return now - disturbances.last >= 5 * MIN ? 0 : disturbances.count; -} -export function disturb(kind: Disturbance) { - if (!store.state.pet || !store.state.asleep) return false; - disturbances.count = disturbanceCount() + 1; - disturbances.last = Date.now(); - const wakes = wakeRoll() < Wd.wakeChance(store.state.stats.energy, kind, disturbances.count); - if (wakes) wake(); - return wakes; -} -export function wake() { - if (!store.state.pet || !store.state.asleep) return; - store.state.asleep = false; - store.state.wokeAt = Date.now(); - resetDisturbances(); - view.dream = null; - view.anim = null; - pushLine(Wd.events.woke(store.state.pet!.name)); -} -export function dream(item: string) { - view.dream = item; - view.dreamUntil = performance.now() + 25e3; - rollDay(); - const entry = `dreamed of ${Wd.dreamThing(item)}`; - if (store.state.today.log.includes(entry)) return; - // Keep at most two dreams, so frequent dreams cannot displace the whole day. - const old = store.state.today.log.filter((x) => x.startsWith("dreamed of ")); - if (old.length >= 2) store.state.today.log.splice(store.state.today.log.indexOf(old[0]), 1); - logToday(entry); -} export function walk(dir: string) { const d = dir === "left" ? -1 : 1; view.target = clamp(view.x + d * rand(14, 28), 14, LW - 14); view.facing = d; } -export function setWakeRoll(fn: (() => number) | null) { - wakeRoll = typeof fn === "function" ? fn : Math.random; +// Effects come from the shared engine, locally or in a server snapshot. The +// save has already changed; these only animate the LCD and tell the owner. +export function playEffect(e: Effect) { + const pet = store.state.pet?.name || "Your pet", + asleep = store.state.asleep; + if (e.kind === "emote" && L.EMOTES.includes(e.arg)) { + animate(e.arg, e.arg === "spin" ? 1300 : 2000); + if (e.arg === "love" || e.arg === "blush") float("smallheart", 3); + if (e.arg === "dance") float("note", 3); + if (e.arg === "wow") float("bang", 1); + } else if (e.kind === "walk" && L.WALKS.includes(e.arg) && !asleep) walk(e.arg); + else if (e.kind === "want" && L.WANTS.includes(e.arg)) want(e.arg); + else if (e.kind === "dream" && L.DREAMS.includes(e.arg) && asleep) { + view.dream = e.arg; + view.dreamUntil = performance.now() + 25e3; + } else if (e.kind === "eat" && L.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); + // The squish is physics; the feelings come from the brain. + else if (e.kind === "pat") animate(e.arg === "asleep" ? "stretch" : "pat", 600, true); + else if (e.kind === "sparkle") float("sparkle", 3); + else if (e.kind === "gift" && L.GIFTS.includes(e.arg)) { + float(e.arg, 1); + toast(`${pet} gave you a ${e.arg}!`, e.arg); + } else if (e.kind === "remember") toast(`${pet} will remember: ${e.arg}`); + else if (e.kind === "grew") toast(`${pet} grew into a ${e.arg}!`); + else if (e.kind === "sick") toast(`${pet} is feeling sick. Medicine helps.`); + else if (e.kind === "diary") + toast( + e.arg === store.state.today.date + ? `${pet} wrote in the diary` + : `${pet} wrote the page for ${e.arg}`, + "book", + ); + else if (e.kind === "sleep") { + view.target = view.x; + view.want = null; + view.dream = null; + view.queue = []; + animate("sleepy", 1500, true); + } else if (e.kind === "woke") { + view.dream = null; + view.anim = null; + } } diff --git a/web/app/body.ts b/web/app/body.ts deleted file mode 100644 index e0740c1..0000000 --- a/web/app/body.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PetState, Stat, Stats } from "../types.ts"; -import { HOUR, MIN, clamp } from "./utils.ts"; - -export function rates(asleep: boolean): Stats { - return asleep - ? { tummy: -2, fun: 0, energy: +22, clean: -0.5 } - : { tummy: -8, fun: -6, energy: -5, clean: -3 }; -} -export function simulateBody(state: PetState, from: number, to: number, quiet: boolean) { - let t = from; - const evs = []; - while (t < to) { - const dt = Math.min(to - t, quiet ? 10 * MIN : to - t); - const r = rates(state.asleep), - h = dt / HOUR; - for (const k of Object.keys(r) as Stat[]) - state.stats[k] = clamp(state.stats[k] + r[k] * h * (state.sick && k === "fun" ? 2 : 1)); - t += dt; - if (!state.pet) continue; - if (!state.asleep && t >= state.nextPoop) { - if (state.poop < 4) { - state.poop++; - state.stats.clean = clamp(state.stats.clean - 15); - evs.push("pooped"); - } - state.nextPoop = t + (2.5 + Math.random() * 2.5) * HOUR; - } - const grimy = state.poop >= 3 || state.stats.tummy < 5 || state.stats.clean < 5; - if (!grimy) state.grimeSince = 0; - else if (state.asleep) { - if (state.grimeSince) state.grimeSince += dt; - } else if (!state.grimeSince) state.grimeSince = t; - if (!state.asleep && !state.sick && state.grimeSince && t - state.grimeSince > HOUR) { - state.sick = true; - evs.push("sick"); - } - // Choosing to nap is the brain's call ([sleep]); collapsing is the body's, - // but not within 5 minutes of being woken up. - if (!state.asleep && state.stats.energy < 6 && t - (state.wokeAt || 0) > 5 * MIN) { - state.asleep = true; - evs.push("fell asleep"); - } - const hr = new Date(t).getHours(); - if ( - state.asleep && - (state.stats.energy >= 99 || - (state.stats.energy > 70 && hr >= 7 && hr < 11 && !state.lightsOff)) - ) { - state.asleep = false; - state.lightsOff = false; - evs.push("woke"); - } - if (state.stats.tummy < 15) evs.push("hungry"); - if (state.stats.clean < 20) evs.push("stinky"); - } - return evs; -} diff --git a/web/app/brain.ts b/web/app/brain.ts index 4ddd585..934743c 100644 --- a/web/app/brain.ts +++ b/web/app/brain.ts @@ -6,7 +6,7 @@ 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"; +import { editState } from "./engine.ts"; let genId = 0; const pending = new Map< @@ -234,9 +234,11 @@ export async function loadBrain(id = CFG.modelId) { clearTimeout(watchdog); brain.state = "ready"; if (!isRemote()) { - store.state.hosting.brainId = id; + if (store.state.hosting.brainId !== id) + editState((s) => { + s.hosting.brainId = id; + }); brain.remoteFeatures = undefined; - save(); } if (upgrade.state === "swapping") { upgrade.state = "idle"; diff --git a/web/app/canvas.ts b/web/app/canvas.ts index 8a6bb7f..cbd09e6 100644 --- a/web/app/canvas.ts +++ b/web/app/canvas.ts @@ -3,7 +3,7 @@ import { nextAnim } from "./animation.ts"; import { act, eggView } from "./care.ts"; import { OFFER_ICON } from "./config.ts"; import { gameControl, stepGame, stopGameControls } from "./game.ts"; -import { tick, weatherAt } from "./simulation.ts"; +import { tick, weatherNow } from "./simulation.ts"; import { brain, store, view } from "./store.ts"; import { clamp } from "./utils.ts"; import { isRemote } from "./hosting.ts"; @@ -33,7 +33,7 @@ function frame(now: number) { ctx, { lightsOff: store.state.lightsOff, - weather: isRemote() && ui.remoteWeather ? ui.remoteWeather : weatherAt(Date.now()), + weather: isRemote() && ui.remoteWeather ? ui.remoteWeather : weatherNow(), hour: d.getHours(), shelf: store.state.shelf, poop: store.state.poop, diff --git a/web/app/care.ts b/web/app/care.ts index 1a0ecee..d482428 100644 --- a/web/app/care.ts +++ b/web/app/care.ts @@ -1,17 +1,9 @@ -import { BODY_COLORS } from "../scene.ts"; -import type { Action, Offer, OfferKind } from "../types.ts"; -import * as Wd from "../world.ts"; -import { animate, bubbles, disturb, float, wake } from "./animation.ts"; +import type { Action } from "../types.ts"; import { brainHas } from "./brain.ts"; -import { ACT_NEEDS, HOUR } from "./config.ts"; -import { pushLine, wantTurn } from "./conversation.ts"; -import { startGame } from "./game.ts"; -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"; +import { ACT_NEEDS } from "./config.ts"; +import { petCommand } from "./commands.ts"; +import { brain, store, toast, ui, view } from "./store.ts"; +import { isRemote } from "./hosting.ts"; // ---------- egg & hatching ---------- // The egg incubates while the brain downloads: no brain, no pet. @@ -22,163 +14,21 @@ export function eggView() { const p = ready ? 1 : brain.progress ? (0.9 * brain.progress.loaded) / brain.progress.total : 0; return { progress: p, ready }; } -export function hatch(name: string) { - const traits = [...Wd.PERSONALITIES].sort(() => Math.random() - 0.5).slice(0, 2); - store.state.pet = { - name, - traits, - color: pick(Object.keys(BODY_COLORS)), - toy: pick(Wd.TOYS), - hatched: Date.now(), - stage: "baby", - }; - store.state.egg = null; - store.state.today = { - date: dateKey(Date.now()), - log: [], - day: 1, - weather: weatherAt(Date.now()), - }; - store.state.nextPoop = Date.now() + 3 * HOUR; - float("sparkle", 4); - save(); - emit(); - wantTurn(); -} - -// ---------- offers: the owner proposes, the brain decides ---------- -export function offerThing(o: Offer) { - return o.kind === "feed" - ? Wd.a(o.item!) - : o.kind === "play" - ? "catch" - : o.kind === "bath" - ? "a bath" - : "medicine"; -} -export function offer(kind: OfferKind, item?: string) { - const o = store.state.owner, - p = store.state.pet!.name; - if (store.state.offer) { - toast(`${p} is still deciding about ${offerThing(store.state.offer)}.`); - return; - } - store.state.offer = { kind, item: item || null }; - pushLine( - kind === "feed" - ? Wd.events.offerFood(o, p, item!) - : kind === "bath" - ? Wd.events.offerBath(o, p) - : kind === "medicine" - ? Wd.events.offerMedicine(o, p) - : Wd.events.offerCatch(o, p), - ); - emit(); - wantTurn(); -} -export function answerOffer(yes: boolean) { - const o = store.state.offer; - store.state.offer = null; - if (!o) return; - if (!yes) { - logToday(`said no to ${offerThing(o)}`); - emit(); - return; - } - wake(); - if (o.kind === "feed") { - const f = o.item!; - store.state.stats.tummy = clamp( - store.state.stats.tummy + (f === "pudding" || f === "cookie" ? 22 : 28), - ); - if (f === "pudding" || f === "cookie" || f === "strawberry") - store.state.stats.fun = clamp(store.state.stats.fun + 6); - view.effects.push({ - kind: "food", - item: f, - t0: performance.now(), - until: performance.now() + 1700, - }); - animate("eat", 1700, true); - store.state.counts.fed++; - logToday(`ate ${Wd.a(f)}`); - } else if (o.kind === "bath") { - store.state.stats.clean = 100; - store.state.stats.fun = clamp(store.state.stats.fun - 2); - bubbles(14); - store.state.counts.baths++; - logToday("had a bath"); - } else if (o.kind === "medicine") { - if (store.state.sick) { - store.state.sick = false; - store.state.grimeSince = 0; - float("sparkle", 2); - } - logToday("took medicine"); - } else if (o.kind === "play") { - setTimeout(startGame, 600); - } - save(); - emit(); -} // ---------- care ---------- // Feeding, baths, medicine and catch are offers the brain answers. Pats, -// cleaning and the lights just happen, and the brain reacts to them. +// cleaning and the lights just happen, and the brain reacts to them. The +// shared engine applies all of them, locally or on the host. export function act(kind: Action, arg?: string) { 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; + const p = store.state.pet.name; if (brain.state !== "ready") { - toast(`${p} is dozing until its brain is ready.`); + if (!isRemote()) toast(`${p} is dozing until its brain is ready.`); return; } if (!brainHas(ACT_NEEDS[kind])) { toast(`${p} needs its new brain for that.`); return; } - store.state.lastActive = Date.now(); - switch (kind) { - case "feed": - case "bath": - case "medicine": - case "play": - offer(kind, arg); - return; - case "pat": - disturb("touch"); - animate(store.state.asleep ? "stretch" : "pat", 600, true); // the squish is physics; the feelings come from the brain - store.state.stats.fun = clamp(store.state.stats.fun + 4); - store.state.counts.pats++; - // While a reaction to the last pat is pending, more pats just squish. - if (store.state.chat.at(-1) !== Wd.events.pat(o, p)) { - pushLine(Wd.events.pat(o, p)); - logToday("got patted"); - } - wantTurn(); - break; - case "clean": - if (!store.state.poop) { - toast("All clean already."); - return; - } - store.state.poop = 0; - store.state.stats.clean = clamp(store.state.stats.clean + 10); - float("sparkle", 2); - pushLine(Wd.events.cleaned(o)); - wantTurn(); - break; - case "lights": - store.state.lightsOff = !store.state.lightsOff; - pushLine(Wd.events.lights(o, !store.state.lightsOff)); - if (!store.state.lightsOff) disturb("light"); - wantTurn(); - break; - } - save(); - emit(); + void petCommand({ type: "care", kind, item: arg || null }); } diff --git a/web/app/commands.ts b/web/app/commands.ts index 1e88f06..fcd4911 100644 --- a/web/app/commands.ts +++ b/web/app/commands.ts @@ -1,11 +1,9 @@ -import { disturb } from "./animation.ts"; +import { cleanMessage, cleanName } from "../core.ts"; import { forgetBrains, loadBrain, stopBrain } from "./brain.ts"; -import { hatch } from "./care.ts"; -import { pushLine, scheduleIdle, wantTurn } from "./conversation.ts"; -import { logToday } from "./simulation.ts"; -import { BAD_KEY, cancelSave, KEY, parseSave, save } from "./storage.ts"; +import { changed, localCommand, petEngine } from "./engine.ts"; +import { BAD_KEY, cancelSave, KEY, parseSave } from "./storage.ts"; import { brain, emit, store, toast, upgrade } from "./store.ts"; -import { cleanName, dateKey, download, errorMessage } from "./utils.ts"; +import { dateKey, download, errorMessage } from "./utils.ts"; import { isRemote, moveToRemote, @@ -14,84 +12,47 @@ import { recoverySave, deleteRemote, } from "./hosting.ts"; -import type { HostingMode } from "../types.ts"; +import type { HostingMode, PetCommand } from "../types.ts"; import { CFG } from "./config.ts"; import { ui } from "./store.ts"; +/** Owner commands run in the local engine, or go unchanged to the pet's host. */ +export async function petCommand(command: PetCommand) { + if (!store.tabOwned || ui.hostingBusy) return false; + return isRemote() ? remoteCommand(command) : localCommand(command); +} + 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(); + if (!store.tabOwned || store.badEgg || ui.hostingBusy || !cleanName(name, 24)) return; + petEngine().layEgg(name, Date.now()); + changed(); 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); + if (value && store.tabOwned && !store.badEgg && brain.state === "ready") + void petCommand({ type: "hatch", name: value }); } 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 || ui.hostingBusy) - return false; - if (isRemote()) return remoteCommand({ type: "message", text }); - store.state.lastActive = Date.now(); - disturb("talk"); - pushLine(`${store.state.owner}: ${text}`); - if (text.length > 12 && Math.random() < 0.5) - logToday(`${store.state.owner} said: ${text.slice(0, 48).toLowerCase()}`); - wantTurn(); - return true; + const text = cleanMessage(value); + if (!text || !store.state.pet || brain.state !== "ready") return false; + return petCommand({ type: "message", text }); } 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(); - emit(); - } + if (name) void petCommand({ type: "rename", name }); } 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(); + void petCommand({ type: "chatter", value }); } 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(); + const fact = store.state.memories[index]; + if (fact !== undefined) void petCommand({ type: "forgetMemory", index, fact }); } export async function deleteBrain() { if (ui.hostingBusy || isRemote()) return; diff --git a/web/app/config.ts b/web/app/config.ts index 150adc6..4dcdfa2 100644 --- a/web/app/config.ts +++ b/web/app/config.ts @@ -1,5 +1,4 @@ -import type { Action, Config, Stage, Stat } from "../types.ts"; -import { DAY, HOUR } from "./utils.ts"; +import type { Action, Config, Stat } from "../types.ts"; const element = document.getElementById("pet-config"); if (!element?.textContent) throw new Error("Missing #pet-config JSON configuration"); export const CFG: Config = JSON.parse(element.textContent); @@ -44,11 +43,5 @@ export const NAME_IDEAS = [ // Display order of the meters. Saves can arrive with keys in another order // (the host serializes them alphabetically), so never render by key order. export const STATS: readonly Stat[] = ["tummy", "fun", "energy", "clean"]; -export const STAGE_AGE: [Stage, number][] = [ - ["adult", 5 * DAY], - ["teen", 2 * DAY], - ["kid", 12 * HOUR], - ["baby", 0], -]; export const FEATURE_TEXT: Record = {}; export const ACT_NEEDS: Partial> = {}; diff --git a/web/app/conversation.ts b/web/app/conversation.ts index cebb4cd..0fc597f 100644 --- a/web/app/conversation.ts +++ b/web/app/conversation.ts @@ -1,118 +1,48 @@ +import { cleanText, notCute } from "../core.ts"; import type { TurnKind } from "../types.ts"; -import * as Wd from "../world.ts"; -import { animate, dream, float, walk, want } from "./animation.ts"; import { gen } from "./brain.ts"; -import { answerOffer } from "./care.ts"; -import { MIN } from "./config.ts"; -import { onSleep } from "./diary.ts"; -import { logToday, worldState } from "./simulation.ts"; -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 { writeDiary } from "./diary.ts"; +import { changed, petEngine } from "./engine.ts"; +import { brain, emit, liveBubble, store, view } from "./store.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)); -export function pushLine(line: string) { - // Keep at most two silent pet lines (only tags: walking, emotes) in a row so - // idling does not push the conversation out of the model's context. - const p = store.state.pet?.name; - const silent = (l: string) => p && l.startsWith(p + ": ") && isSilent(l.slice(p.length + 2)); - if ( - silent(line) && - silent(store.state.chat.at(-1) || "") && - silent(store.state.chat.at(-2) || "") - ) - store.state.chat.splice(-2, 1); - store.state.chat.push(line); - if (store.state.chat.length > 240) store.state.chat.splice(0, store.state.chat.length - 240); - lineCount++; - save(); - emit(); -} - -const LOOKUP_TAIL = /\[(clock|look|yesterday|recall)(?::([^\]\n]{1,80}))?\]$/; - -// One pet line, with up to three lookup round-trips (the "agentic" bit). -// Idle turns see a shorter history: prefill is most of their cost. -async function petLine(kind: TurnKind, live: (text: string) => void) { - const st = worldState(); - const header = Wd.header("chat", st); - let text = ""; - for (let hop = 0; hop < 4; hop++) { - // End the prompt at "Name:" (no trailing space): GPT-2 merges the space - // into the next token, so a bare trailing space derails it. - const r = await gen( - { - header, - lines: store.state.chat, - prefix: `${store.state.pet!.name}:${text}`, - mode: "line", - maxNew: kind === "idle" ? 40 : 64, - temperature: 0.85, - ctx: kind === "idle" ? 512 : 1024, - }, - (partial) => live(text + partial), - ); - brain.tps = r.tps || brain.tps; - brain.prefillMs = r.prefillMs; - text += r.text; - const m = r.reason === "lookup" && text.match(LOOKUP_TAIL); - if (!m || hop === 3) break; - text += `{${Wd.toolResult(st, m[1], m[2])}}`; - live(text); - } - return text.trim().replace(/\s+/g, " "); -} - -// Turn scheduling. "react" turns answer something that happened (a message, -// an offer, a pat); "idle" turns let the pet live on its own: walk around, -// mutter, want things, nap. A react request replaces a waiting idle one. -export let turnWanted: TurnKind | null = null; -let nextIdle = Date.now() + 6000; +// Turn scheduling lives in the shared engine: "react" turns answer something +// that happened (a message, an offer, a pat), "idle" turns let the pet live on +// its own, and diary pages are written at bedtime or for missed days. This +// module runs whatever the engine asks for while the brain is free. A hidden +// page still answers reactions but does not idle or write. export function wantTurn(kind: TurnKind = "react") { - if (kind === "react" || !turnWanted) turnWanted = kind; + petEngine().wantTurn(kind); setTimeout(pump, 0); } export function pump() { if ( !canRunLocally() || !store.tabOwned || - !turnWanted || brain.busy || brain.state !== "ready" || !store.state.pet || view.game ) return; - const kind = turnWanted; - turnWanted = null; - petTurn(kind).finally(() => { - if (turnWanted) setTimeout(pump, 120); + const e = petEngine(), + now = Date.now(); + const task = e.chooseTask(now, !document.hidden); + if (!task) return; + e.beginTurn(now); + const run = task.type === "chat" ? petTurn(task.kind) : writeDiary(task.date); + void run.finally(() => { + if (petEngine().pending) setTimeout(pump, 120); }); } -function idleGap() { - if (store.state.asleep) return rand(3, 6) * MIN; - return store.state.settings.chatter ? rand(8e3, 20e3) : rand(2, 4) * MIN; -} -export function maybeIdle() { - if ( - !canRunLocally() || - brain.state !== "ready" || - brain.busy || - turnWanted || - view.game || - document.hidden || - !store.state.pet - ) - return; - if (Date.now() < nextIdle) return; - nextIdle = Date.now() + idleGap(); - wantTurn("idle"); +/** The first idle turn comes this long after the brain is ready. */ +export function scheduleIdle(delay: number) { + petEngine().idleAt(Date.now() + delay); } -let staleRuns = 0; +// One pet line: the engine builds each request, appends lookup results and +// filters the text; the worker only generates. Idle turns run without the +// thinking bubble. export async function petTurn(kind: TurnKind = "react") { if ( !canRunLocally() || @@ -122,113 +52,44 @@ export async function petTurn(kind: TurnKind = "react") { brain.busy ) return false; - const execution = store.executionRevision; + const e = petEngine(), + execution = store.executionRevision, + seen = e.contextRevision; + const turn = e.chatTurn(kind, Date.now()); brain.busy = true; - emit(); view.thinking = kind === "react"; - const seen = lineCount, - offer = store.state.offer, - wasAsleep = store.state.asleep; + emit(); let bubble = kind === "react" ? liveBubble() : null; const live = (t: string) => { - const shown = NOT_CUTE.test(t) ? "…" : cleanText(t); + const shown = notCute(t) ? "…" : cleanText(t); if (!bubble && !shown) return; // silent so far: nothing to show (bubble ||= liveBubble()).update(shown); }; try { - let text = ""; - for (let tries = 0; tries < 3; tries++) { - text = await petLine(kind, live); - if (!NOT_CUTE.test(text)) break; - text = ""; + for (;;) { + const r = await gen(turn.request(), (partial) => live(turn.text() + partial)); + brain.tps = r.tps || brain.tps; + brain.prefillMs = r.prefillMs; + if (!turn.feed(r.text, r.reason)) break; + live(turn.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)) { - staleRuns++; - turnWanted = "react"; - return true; - } - staleRuns = 0; - if (!text || /^[\W_]*$/.test(text)) { - if (offer && store.state.offer === offer) answerOffer(false); - return true; - } - pushLine(`${store.state.pet!.name}: ${text}`); - applyTags(text); - // An offer the pet saw but did not answer counts as a no. - if (offer && store.state.offer === offer) answerOffer(false); - emit(); - } catch (e) { + // Something happened while it was thinking (a message, an offer, a pat, falling + // asleep): the line is stale. Whatever changed asked for the next turn. + if (e.contextRevision !== seen) return true; + e.commitLine(turn.body() ?? "", Date.now()); + changed(); + } catch (err) { bubble?.remove(); - console.warn(e); + console.warn(err); } finally { + turn.free(); 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])); - // Resolve the answer once, independent of tag order; ambiguous answers do nothing. - if (store.state.offer && answers.length === 1 && !answers[0][2] && !answers[0][3]) - answerOffer(answers[0][1] === "yes"); - for (const m of tags) { - const [, name, arg, result] = m; - if (result && Wd.EFFECT_TOOLS.has(name)) continue; - if (name === "dream") { - if (sleeping && store.state.asleep && Wd.DREAMS.includes(arg)) dream(arg); - continue; - } - // A sleeping answer can only dream or accept/refuse the offer, even if - // accepting it has just woken the pet. All other effects wait for next turn. - if (sleeping || store.state.asleep) continue; - if (name === "emote" && Wd.EMOTES.includes(arg)) { - animate(arg, arg === "spin" ? 1300 : 2000); - if (arg === "love" || arg === "blush") float("smallheart", 3); - if (arg === "dance") float("note", 3); - if (arg === "wow") float("bang", 1); - } else if (name === "want" && Wd.WANTS.includes(arg)) { - want(arg); - } else if (name === "walk" && Wd.WALKS.includes(arg)) { - walk(arg); - } else if (name === "remember" && arg) { - const fact = arg.trim(); - if (!store.state.memories.some((x) => x.toLowerCase() === fact.toLowerCase())) { - store.state.memories.push(fact); - if (store.state.memories.length > 60) store.state.memories.shift(); - logToday(`learned: ${fact}`); - toast(`${store.state.pet!.name} will remember: ${fact}`); - } - } else if (name === "gift" && Wd.GIFTS.includes(arg)) { - store.state.shelf.push(arg); - if (store.state.shelf.length > 24) store.state.shelf.shift(); - logToday(`gave ${store.state.owner} a ${arg}`); - toast(`${store.state.pet!.name} gave you a ${arg}!`, arg); - float(arg, 1); - } else if (name === "diary" && arg) { - logToday(`note: ${arg.trim()}`); - } else if (name === "sleep" && !arg && !store.state.asleep) { - store.state.asleep = true; - logToday("napped"); - onSleep(); - } - } - save(); - emit(); -} - -export function scheduleIdle(delay = idleGap()) { - nextIdle = Date.now() + delay; -} diff --git a/web/app/debug.ts b/web/app/debug.ts index 4b333a4..90258a8 100644 --- a/web/app/debug.ts +++ b/web/app/debug.ts @@ -1,11 +1,9 @@ -import type { Generated, Generation } from "../types.ts"; -import { disturb, disturbances, resetDisturbances, setWakeRoll } from "./animation.ts"; +import type { Disturbance, Generated, Generation, PetState } from "../types.ts"; import { act } from "./care.ts"; import { DEBUG } from "./config.ts"; -import { applyTags, petTurn, wantTurn } from "./conversation.ts"; +import { petTurn, wantTurn } from "./conversation.ts"; import { writeDiary } from "./diary.ts"; -import { simulate, stageFor } from "./simulation.ts"; -import { save } from "./storage.ts"; +import { changed, editState, petEngine } from "./engine.ts"; import { brain, emit, store, toast, ui, upgrade, view } from "./store.ts"; import { canRunLocally } from "./hosting.ts"; export function debugTurn(opts: Generation, result: Generated) { @@ -15,20 +13,41 @@ 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; - pet.stage = stageFor(ms); - save(); - emit(); + if (!canRunLocally() || !store.state.pet) return; + petEngine().setAge(ms, Date.now()); + changed(); } export const copyText = (text: string) => navigator.clipboard.writeText(text).then( () => toast("Copied."), () => toast("Couldn't copy."), ); -const debug = { setWakeRoll, disturb, disturbances, resetDisturbances, applyTags, simulate }; +// Debug controls run the same engine paths as the pet; tags applied here +// behave as if the brain had written them. +const debug = { + setWakeRoll: (roll: (() => number) | null) => petEngine().setWakeRoll(roll), + disturb: (kind: Disturbance) => { + const woke = petEngine().disturb(kind, Date.now()); + changed(); + return woke; + }, + get disturbances() { + return petEngine().disturbances; + }, + resetDisturbances: () => petEngine().resetDisturbances(), + applyTags: (text: string) => { + if (!canRunLocally()) return; + petEngine().applyLine(text, Date.now()); + changed(); + }, + simulate: (from: number, to: number, quiet: boolean) => { + const events = petEngine().simulate(from, to, quiet); + changed(); + return events; + }, + /** Edits the save in place, e.g. `edit((s) => { s.stats.tummy = 15 })`. */ + edit: (fn: (state: PetState) => void) => editState(fn), +}; export function testControls() { return { state: () => store.state, diff --git a/web/app/diary.ts b/web/app/diary.ts index 9974f65..4818ce8 100644 --- a/web/app/diary.ts +++ b/web/app/diary.ts @@ -1,123 +1,39 @@ -import { SPRITES } from "../sprites.ts"; -import * as Wd from "../world.ts"; -import { animate, resetDisturbances } from "./animation.ts"; +import { cleanText } from "../core.ts"; import { gen } from "./brain.ts"; import { DEBUG } from "./config.ts"; -import { pump, turnWanted } from "./conversation.ts"; -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 { changed, petEngine } from "./engine.ts"; +import { brain, emit, store, ui } from "./store.ts"; import { canRunLocally } from "./hosting.ts"; -// ---------- diary ---------- -export function onSleep() { - resetDisturbances(); - view.dream = null; - view.want = null; - store.state.lightsOff = store.state.lightsOff || isNight(); - view.queue = []; - view.target = view.x; - animate("sleepy", 1500, true); - if ( - isNight() && - store.state.today.log.length >= 2 && - !store.state.diary.some((e) => e.date === store.state.today.date) - ) { - setTimeout(() => writeDiary(), 1500); - } -} -const isNight = () => Wd.partOfDay(new Date().getHours()) === "night"; - -// Pages are written by the pet, never on request: today's at bedtime -// (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 || - brain.state !== "ready" || - brain.busy || - turnWanted || - view.game || - document.hidden || - !store.state.pet - ) - return; - if (Date.now() - store.state.lastActive < 30e3) return; - writeDiary(k); -} - -export async function writeDiary(dateKeyOverride?: string, rewrite = false) { +// Pages are written by the pet, never on request: today's at bedtime, and +// missed days one at a time, oldest first, when the brain is free and the +// owner has left it alone for 30 s. The engine chooses when (see pump()); a +// page with unsafe text is dropped and its day stays pending. +export async function writeDiary(date?: string, rewrite = 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 - ? { log: store.state.today.log, weather: weatherAt(Date.now()), day: dayNum() } - : store.state.history[k]; - const replace = DEBUG && rewrite && k === store.state.today.date; - if (!src || (!replace && store.state.diary.some((e) => e.date === k))) return false; + const e = petEngine(); + const turn = e.diaryTurn(date || store.state.today.date, Date.now(), DEBUG && rewrite); + if (!turn) return false; brain.busy = true; ui.diaryWriting = true; ui.diaryDraft = "dear diary,"; emit(); try { - const st = { ...worldState(), today: src.log, weather: src.weather, day: src.day }; - const r = await gen( - { - header: Wd.header("diary", st), - lines: [], - prefix: "dear diary,", - mode: "diary", - maxNew: 220, - temperature: 0.85, - }, - (t) => { - ui.diaryDraft = "dear diary," + t.replace(/\[doodle:\w+\]/g, ""); - emit(); - }, - ); - 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+/) - .filter((x) => !NOT_CUTE.test(x)) - .join(" "); - const doodles = [...text.matchAll(/\[doodle:(\w+)\]/g)] - .map((m) => m[1]) - .filter((d) => SPRITES[d]) - .slice(0, 3); - text = text - .replace(/\[doodle:\w+\]/g, "") - .replace(/\[[^\]]*\]/g, "") - .trim(); - if (replace) store.state.diary = store.state.diary.filter((e) => e.date !== k); - store.state.diary.push({ - date: k, - day: src.day, - weather: src.weather, - stage: store.state.pet!.stage, - text, - doodles, + const r = await gen(turn.request(), (t) => { + ui.diaryDraft = cleanText("dear diary," + t); + emit(); }); - delete store.state.history[k]; - save(); - toast( - k === store.state.today.date - ? `${store.state.pet!.name} wrote in the diary` - : `${store.state.pet!.name} wrote the page for ${k}`, - "book", - ); - } catch (e) { - console.warn(e); + if (execution !== store.executionRevision || !canRunLocally() || !store.tabOwned) return false; + if (!e.commitDiary(turn, r.text)) console.warn("diary page filtered; the day stays pending"); + changed(); + } catch (err) { + console.warn(err); } finally { + turn.free(); if (execution === store.executionRevision) brain.busy = false; ui.diaryWriting = false; emit(); - pump(); } return true; } diff --git a/web/app/engine.ts b/web/app/engine.ts new file mode 100644 index 0000000..5deb1d4 --- /dev/null +++ b/web/app/engine.ts @@ -0,0 +1,59 @@ +import { Engine, type Effect } from "../core.ts"; +import type { PetCommand, PetState } from "../types.ts"; +import { save } from "./storage.ts"; +import { emit, store, toast } from "./store.ts"; +import { errorMessage } from "./utils.ts"; + +// The browser's copy of the pet lives in the shared Rust engine (core/), the +// same one the host runs. `store.state` is a read-only snapshot for rendering. +// In remote mode the engine only holds the server's latest checkpoint. +let engine: Engine | null = null; +export function petEngine(): Engine { + if (!engine) throw new Error("The pet has not loaded yet."); + return engine; +} +/** `seed` is the deployment's weather seed, shared with its host. */ +export function startEngine(state: PetState, seed: string) { + engine?.raw.free(); + engine = new Engine(state, Date.now(), seed); + store.state = engine.state(); +} +let onChange: ((effects: Effect[]) => void) | null = null; +/** Runs after every change with its new effects (lifecycle.ts plays them). */ +export function afterChanges(hook: (effects: Effect[]) => void) { + onChange = hook; +} +/** Refreshes the snapshot (only the body fields when `full` is false), hands on new effects and saves. */ +export function changed(full = true) { + const e = petEngine(); + store.state = full ? e.state() : { ...store.state, ...e.body() }; + const effects = e.takeEffects(); + onChange?.(effects); + save(); + emit(); +} +/** Replaces the save: an import, a transfer or a server snapshot. */ +export function setState(state: PetState, keepTurns = false) { + const e = petEngine(); + if (keepTurns) e.edit(state); + else e.replace(state, Date.now()); + changed(); +} +/** Debug and test edits of the save, keeping the turn queue. */ +export function editState(edit: (state: PetState) => void) { + const state = petEngine().state(); + edit(state); + setState(state, true); +} +/** Runs an owner command locally. Refusals become a toast. */ +export function localCommand(command: PetCommand) { + try { + petEngine().command(command, Date.now()); + return true; + } catch (error) { + toast(errorMessage(error)); + return false; + } finally { + changed(); + } +} diff --git a/web/app/game.ts b/web/app/game.ts index 298a948..86b9078 100644 --- a/web/app/game.ts +++ b/web/app/game.ts @@ -1,15 +1,26 @@ import { FLOOR, W as LW } from "../scene.ts"; -import * as Wd from "../world.ts"; import { animate } from "./animation.ts"; -import { pushLine, wantTurn } from "./conversation.ts"; -import { logToday } from "./simulation.ts"; -import { save } from "./storage.ts"; -import { emit, store, view } from "./store.ts"; +import { petCommand } from "./commands.ts"; +import { petEngine } from "./engine.ts"; +import { emit, store, ui, view } from "./store.ts"; import { clamp } from "./utils.ts"; -import { isRemote, remoteCommand } from "./hosting.ts"; -import { ui } from "./store.ts"; +import { isRemote } from "./hosting.ts"; // ---------- catch minigame (after the pet said yes) ---------- +// The engine records the accepted game; this module plays it and reports the +// score once, by the game's id. +export function gameId() { + return isRemote() ? ui.remoteGameId : petEngine().game?.id || null; +} +let starting = 0; +/** Starts the game the pet accepted, after its answer has had a moment on screen. */ +export function startAcceptedGame() { + if (view.game || starting || !gameId()) return; + starting = setTimeout(() => { + starting = 0; + if (gameId()) startGame(); + }, 600); +} export function startGame() { if (!store.tabOwned || !store.state.pet || store.state.asleep || view.game) return; view.game = { @@ -78,21 +89,10 @@ export function stepGame(now: number) { if (g.timeLeft <= 0) endGame(); } export function endGame() { - const n = view.game!.score; + const score = view.game!.score, + id = gameId(); 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++; - pushLine(Wd.events.caught(store.state.owner, store.state.pet!.name, n)); - logToday(`played catch (${n} ${n === 1 ? "star" : "stars"})`); - save(); - emit(); - wantTurn(); + if (id) void petCommand({ type: "gameComplete", gameId: id, score }); } diff --git a/web/app/hosting.ts b/web/app/hosting.ts index cb0fcd9..45e8790 100644 --- a/web/app/hosting.ts +++ b/web/app/hosting.ts @@ -1,11 +1,12 @@ -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 type { HostingMode, PetCommand, PetState, RemoteSnapshot } from "../types.ts"; +import { playEffect } from "./animation.ts"; import { BRAINS, loadBrain, stopBrain } from "./brain.ts"; import { CFG } from "./config.ts"; -import { startGame, stopGameControls } from "./game.ts"; +import { setState } from "./engine.ts"; +import { startAcceptedGame, stopGameControls } from "./game.ts"; +import { timeZone } from "./save-format.ts"; import { catchUp, resetSimulationClock } from "./simulation.ts"; -import { parseSave, save, saveNow } from "./storage.ts"; +import { parseSave, saveNow } from "./storage.ts"; import { brain, emit, store, toast, ui, view } from "./store.ts"; import { errorMessage } from "./utils.ts"; @@ -125,36 +126,12 @@ async function request( clearTimeout(timeout); } } +// The host runs the same engine; its effects play exactly like local ones. 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); - } + playEffect(e); } } function adopt(snapshot: RemoteSnapshot, c: Connection) { @@ -180,7 +157,7 @@ function adopt(snapshot: RemoteSnapshot, c: Connection) { // 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; + setState(next); seen = { id: c.id, epoch: snapshot.epoch, revision: snapshot.revision }; ui.remoteWeather = snapshot.weather; ui.remoteLastSync = Date.now(); @@ -207,7 +184,7 @@ function adopt(snapshot: RemoteSnapshot, c: Connection) { initializedEffects = true; } applyEffects(snapshot); - if (snapshot.game && !view.game && !ui.hostingBusy) startGame(); + if (snapshot.game && !ui.hostingBusy) startAcceptedGame(); saveNow(); emit(); if (handedHere) { @@ -269,7 +246,7 @@ export async function resumeRemote() { ui.remoteEndpoint = connection()?.base || CFG.remoteEndpoint || ""; await syncRemote(); } -export async function remoteCommand(command: RemoteCommand) { +export async function remoteCommand(command: PetCommand) { if (!isRemote() || !store.tabOwned || ui.hostingBusy || commanded) return false; const c = connection(); if (!c) { @@ -371,8 +348,11 @@ export async function moveToRemote(address: string) { 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"; + // The host keeps the pet's clock in this device's current time zone. + setState({ + ...store.state, + hosting: { mode: "remote", brainId: modelId, timeZone: timeZone() }, + }); 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, { @@ -389,10 +369,8 @@ export async function moveToRemote(address: string) { } 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; + setState(!previous.pet && !hadBrain ? { ...previous, egg: null } : previous); resetSimulationClock(); - save(); if (previous.pet || hadBrain) void loadBrain(previous.hosting.brainId || CFG.modelId); } ui.remoteError = errorMessage(error); @@ -444,7 +422,7 @@ export async function moveToLocal(recover = false) { state: null, }); } - store.state = parseSave(JSON.stringify(snapshot.state)); + setState(parseSave(JSON.stringify(snapshot.state))); c.epoch = snapshot.epoch; delete c.activation; delete c.release; @@ -455,8 +433,11 @@ export async function moveToLocal(recover = false) { keepConnection(c); queueStop(c); } - store.state.hosting.mode = "local"; - store.state.settings = { ...preferences, chatter: store.state.settings.chatter }; + setState({ + ...store.state, + hosting: { ...store.state.hosting, mode: "local" }, + settings: { ...preferences, chatter: store.state.settings.chatter }, + }); brain.remoteFeatures = undefined; ui.remoteGameId = null; ui.remoteError = ""; diff --git a/web/app/lifecycle.ts b/web/app/lifecycle.ts index 76515e5..3aec20a 100644 --- a/web/app/lifecycle.ts +++ b/web/app/lifecycle.ts @@ -1,11 +1,15 @@ import { BRAINS, cachedBrains, loadBrain, stopBrain, updateStorage } from "./brain.ts"; import { CFG } from "./config.ts"; -import { stopGameControls } from "./game.ts"; +import { playEffect } from "./animation.ts"; +import { pump } from "./conversation.ts"; +import { afterChanges, startEngine } from "./engine.ts"; +import { startAcceptedGame, 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 { + canRunLocally, isRemote, releaseRecoveredCopy, resumeRemote, @@ -68,8 +72,16 @@ export async function boot() { /* optional */ } store.tabOwned = await claimTab(steal); - store.state = store.tabOwned ? load() : fresh(); + startEngine(store.tabOwned ? load() : fresh(), CFG.weatherSeed || ""); if (!store.tabOwned) return; + // After every change: animate its effects; locally, also start a game the + // pet accepted and run the turns it wants. + afterChanges((effects) => { + for (const effect of effects) playEffect(effect); + if (!canRunLocally()) return; + startAcceptedGame(); + setTimeout(pump, 0); + }); addEventListener("pagehide", () => { void planAlerts(); lostTab(); diff --git a/web/app/pwa.ts b/web/app/pwa.ts index 5596459..6e12b79 100644 --- a/web/app/pwa.ts +++ b/web/app/pwa.ts @@ -1,9 +1,7 @@ import type { InstallPrompt } from "../types.ts"; -import { simulateBody } from "./body.ts"; import { modelsBase, olderBrain } from "./brain.ts"; -import { CFG, DAY, HOUR, MIN } from "./config.ts"; -import { stageFor } from "./simulation.ts"; -import { save } from "./storage.ts"; +import { CFG, HOUR } from "./config.ts"; +import { changed, petEngine } from "./engine.ts"; import { brain, emit, store, ui } from "./store.ts"; // sw.js (beside the page) keeps the page, the app and the runtime for offline @@ -19,7 +17,9 @@ export const swReady = return null; }) : Promise.resolve(null); -const APP_FILES = ["main.js", "brain.js"].map((f) => new URL(f, import.meta.url).href); +const APP_FILES = ["main.js", "brain.js", "tokenling_core_bg.wasm"].map( + (f) => new URL(f, import.meta.url).href, +); export function pageResources(more: string[] = []) { const page = location.href.split(/[?#]/)[0]; const seen = performance.getEntriesByType("resource").map((e) => e.name); @@ -71,34 +71,27 @@ async function alertsUrl() { const reg = await swReady; return reg && new URL("tokenling-alerts.json", reg.scope).href; } +const ALERT_TEXT: Record [string, string]> = { + hungry: (n) => [`${n} is getting hungry`, "A snack would help."], + poop: (n) => [`${n} made a mess`, "Time to clean up."], + sick: (n) => [`${n} isn't feeling well`, "Medicine helps."], + grew: (n, stage) => [`${n} grew into a ${stage}!`, "Come and see."], +}; +// The engine projects a copy of the body a day ahead (core/src/body.rs). export function alertPlan() { - if (!store.state.pet) return []; - const state = structuredClone(store.state), - n = store.state.pet.name, - items: { kind: string; at: number; title: string; body: string }[] = []; - const add = (kind: string, at: number, title: string, body: string) => { - if (!items.some((i) => i.kind === kind)) items.push({ kind, at, title, body }); - }; - let t = Date.now(); - const hungry0 = state.stats.tummy < 20, - sick0 = state.sick; - let poop = state.poop, - stage = state.pet!.stage; - for (const end = t + DAY; t < end && items.length < 4; t += 10 * MIN) { - simulateBody(state, t, t + 10 * MIN, true); - const at = t + 10 * MIN; - if (!hungry0 && state.stats.tummy < 20) - add("hungry", at, `${n} is getting hungry`, "A snack would help."); - if (state.poop > poop) add("poop", at, `${n} made a mess`, "Time to clean up."); - if (!sick0 && state.sick) add("sick", at, `${n} isn't feeling well`, "Medicine helps."); - const st = stageFor(at - state.pet!.hatched); - if (st !== stage) { - stage = st; - add("grew", at, `${n} grew into a ${st}!`, "Come and see."); - } - poop = state.poop; - } - return items; + const name = store.state.pet?.name; + if (!name) return []; + return petEngine() + .alertPlan(Date.now()) + .map(({ kind, at, stage }) => { + const [title, body] = ALERT_TEXT[kind](name, stage || ""); + return { kind, at, title, body }; + }); +} +/** Device preferences: alerts and install prompts. */ +function prefer(key: "alerts" | "alertsAsked" | "installAsked", value: boolean) { + petEngine().setPreference(key, value); + changed(); } // Rewritten whenever the page hides or shows. While it is visible nothing is // due (the worker also skips alerts while a window is in front). @@ -129,12 +122,10 @@ setInterval(async () => { (await swReady)?.active?.postMessage({ type: "alerts" }); }, 60e3); export async function setAlerts(on: boolean) { - store.state.settings.alertsAsked = true; + prefer("alertsAsked", true); if (!on) { - store.state.settings.alerts = false; - save(); + prefer("alerts", false); planAlerts(); - emit(); return; } const reg = await swReady; @@ -148,13 +139,10 @@ export async function setAlerts(on: boolean) { if (perm !== "granted") { ui.alertsNote = "Notifications are blocked for this site; you can allow them in your browser's site settings."; - store.state.settings.alerts = false; - save(); - emit(); + prefer("alerts", false); return; } - store.state.settings.alerts = true; - save(); + prefer("alerts", true); try { await reg.periodicSync?.register(ALERT_TAG, { minInterval: HOUR }); } catch { @@ -172,16 +160,10 @@ export function getTip() { text: `Keep ${s.pet.name} close: install tokenling as an app. It opens from your home screen, works offline, and your browser is less likely to clear its storage.${installEvent ? "" : " Tap Share, then Add to Home Screen."}`, yesLabel: installEvent ? "install" : "got it", yes: () => { - s.settings.installAsked = true; - save(); + prefer("installAsked", true); void installApp(); - emit(); - }, - no: () => { - s.settings.installAsked = true; - save(); - emit(); }, + no: () => prefer("installAsked", true), }; } if (standalone() && !s.settings.alertsAsked && "Notification" in self) @@ -189,11 +171,7 @@ export function getTip() { text: `Want a heads-up when ${s.pet.name} is hungry, made a mess or isn't feeling well? Alerts stay off unless you turn them on.`, yesLabel: "turn on alerts", yes: () => setAlerts(true), - no: () => { - s.settings.alertsAsked = true; - save(); - emit(); - }, + no: () => prefer("alertsAsked", true), }; return null; } diff --git a/web/app/save-format.ts b/web/app/save-format.ts index f1c90f9..42f28a6 100644 --- a/web/app/save-format.ts +++ b/web/app/save-format.ts @@ -1,75 +1,18 @@ +import { freshSave, parseSave as parse, saveVersion } from "../core.ts"; import type { PetState } from "../types.ts"; -import { dateKey, HOUR } from "./utils.ts"; +// Save versions and migrations live in the shared rules (core/src/save.rs). 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 timeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +export const VERSION = () => saveVersion(); export function fresh(): PetState { - return { - v: 2, - created: Date.now(), - owner: "", - pet: null, - egg: null, - stats: { tummy: 70, fun: 70, energy: 80, clean: 90 }, - sick: false, - poop: 0, - asleep: false, - lightsOff: false, - memories: [], - shelf: [], - chat: [], - diary: [], - today: { date: dateKey(Date.now()), log: [] }, - history: {}, - offer: null, - wokeAt: 0, - lastTick: Date.now(), - lastActive: Date.now(), - grimeSince: 0, - 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", - }, - }; + return freshSave(Date.now(), timeZone()); } -// 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 = 2; -export const MIGRATIONS: ((s: PetState) => PetState)[] = [ - (s) => ({ ...s, v: 2, hosting: fresh().hosting }), -]; +/** Throws with `newer` set for a save from a newer version. */ export function parseSave(raw: string): PetState { - let s: PetState = JSON.parse(raw); - if (!s || typeof s !== "object" || !Number.isInteger(s.v) || s.v < 1) - throw new Error("it isn't a tokenling save"); - if (s.v > VERSION) { - throw Object.assign( - new Error( - `it was saved by a newer version of tokenling (save version ${s.v}, this page reads ${VERSION})`, - ), - { newer: true }, - ); - } - while (s.v < VERSION) { - const from = s.v, - step = MIGRATIONS[from - 1]; - if (!step) throw new Error(`there is no way to update a version ${from} save`); - s = step(s); - if (s?.v !== from + 1) throw new Error(`updating it from version ${from} failed`); - } - 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 }; + const result = parse(raw, Date.now(), timeZone()); + if ("state" in result) return result.state; + throw Object.assign(new Error(result.error), { newer: result.newer }); } diff --git a/web/app/simulation.ts b/web/app/simulation.ts index a8f5d97..d15bec7 100644 --- a/web/app/simulation.ts +++ b/web/app/simulation.ts @@ -1,199 +1,46 @@ import type { WorldState } from "../types.ts"; -import * as Wd from "../world.ts"; -import { resetDisturbances } from "./animation.ts"; -import { simulateBody } from "./body.ts"; -import { CFG, DAY, HOUR, MIN, STAGE_AGE } from "./config.ts"; -import { maybeIdle, pushLine, wantTurn } from "./conversation.ts"; -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 { pump } from "./conversation.ts"; +import { changed, petEngine } from "./engine.ts"; +import { store } from "./store.ts"; import { canRunLocally } from "./hosting.ts"; -function hashStr(s: string) { - let h = 2166136261; - for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619); - return h >>> 0; -} -export function weatherAt(t: number) { - const d = new Date(t), - h = d.getHours(), - night = Wd.partOfDay(h) === "night"; - const r = hashStr(dateKey(t) + (night ? "n" : "d") + (CFG.weatherSeed || "")); - const day = [ - "sunny", - "sunny", - "cloudy", - "cloudy", - "rainy", - "windy", - "foggy", - d.getMonth() < 2 || d.getMonth() == 11 ? "snowy" : "sunny", - ]; - const nite = [ - "clear", - "starry", - "starry", - "cloudy", - "rainy", - "windy", - "clear", - d.getMonth() < 2 || d.getMonth() == 11 ? "snowy" : "clear", - ]; - return (night ? nite : day)[r % 8]; +// The body runs in the shared engine; this module only drives its clock. +export const age = () => (store.state.pet ? Date.now() - store.state.pet.hatched : 0); +export const dayNum = () => petEngine().dayNum(Date.now()); +let weather = { at: 0, value: "" }; +/** The local weather, read at most once a second (the LCD draws every frame). */ +export function weatherNow() { + const now = Date.now(); + if (now - weather.at >= 1000) weather = { at: now, value: petEngine().weather(now) }; + return weather.value; } -export const age = () => (store.state.pet ? Date.now() - store.state.pet!.hatched : 0); -export const dayNum = () => Math.floor(age() / DAY) + 1; -export const stageFor = (ms: number) => STAGE_AGE.find(([, a]) => ms >= a)![0]; - export function worldState(): WorldState { - const d = new Date(); - const last = store.state.diary.filter((e) => e.date !== store.state.today.date).at(-1); - let yesterday = ""; - if (last) { - const first = last.text - .split(/(?<=[.!?])\s/)[0] - .replace(/\s+/g, " ") - .slice(0, 140); - yesterday = /^dear diary/i.test(first) ? first : `dear diary, ${first}`; - } - return { - pet: store.state.pet!.name, - owner: store.state.owner, - stage: store.state.pet!.stage, - day: dayNum(), - traits: store.state.pet!.traits, - hh: d.getHours(), - mm: d.getMinutes(), - weather: weatherAt(Date.now()), - stats: store.state.stats, - memories: store.state.memories, - today: store.state.today.log, - shelf: store.state.shelf, - toy: store.state.pet!.toy, - poop: store.state.poop, - asleep: store.state.asleep, - sick: store.state.sick, - yesterday, - }; -} - -// ---------- day log ---------- -export function logToday(entry: string) { - rollDay(); - const log = store.state.today.log; - const i = log.indexOf(entry); - if (i >= 0) log.splice(i, 1); - log.push(entry); - if (log.length > 14) log.shift(); - save(); - emit(); -} -// At midnight a day with no page yet waits in store.state.history; maybeDiary() has the -// pet write it later. Days with fewer than two things in them get no page, and -// only the newest week is kept. -export function rollDay() { - const k = dateKey(Date.now()); - if (store.state.today.date === k) return; - if ( - store.state.today.log.length >= 2 && - !store.state.diary.some((e) => e.date === store.state.today.date) - ) { - store.state.history[store.state.today.date] = { - log: store.state.today.log, - weather: store.state.today.weather || weatherAt(Date.now() - DAY), - day: store.state.today.day || dayNum() - 1, - }; - } - for (const old of Object.keys(store.state.history).sort().slice(0, -7)) - delete store.state.history[old]; - store.state.today = { date: k, log: [], day: dayNum(), weather: weatherAt(Date.now()) }; -} - -// ---------- the body: stats, poop, sickness, exhaustion ---------- -export function simulate(from: number, to: number, quiet: boolean) { - return simulateBody(store.state, from, to, quiet); + const world = petEngine().world(Date.now()); + if (!world) throw new Error("An egg has no world state."); + return world; } +// After an absence the engine catches up quietly (at most a week) and the pet +// notices its owner coming back after half an hour. export function catchUp() { if (!canRunLocally()) return; const now = Date.now(); - const away = now - store.state.lastTick; - if (store.state.pet && away > 2 * MIN) { - const evs = simulate(store.state.lastTick, Math.min(now, store.state.lastTick + 7 * DAY), true); - lastSecond = now; // the next frame must not replay time already caught up - if (evs.includes("pooped")) logToday("pooped"); - if (evs.includes("hungry")) logToday("was very hungry"); - if (evs.includes("stinky")) logToday("was stinky"); - if (evs.includes("fell asleep") || evs.includes("woke")) logToday("napped"); - if (away > 30 * MIN) { - const span = - away > 2 * DAY - ? `${Math.floor(away / DAY)} days` - : away > 2 * HOUR - ? `${Math.floor(away / HOUR)} hours` - : `${Math.floor(away / MIN)} minutes`; - pushLine(Wd.events.back(store.state.owner, span)); - logToday(`${store.state.owner} was away for a while`); - wantTurn(); - } - if (evs.includes("woke") && !store.state.asleep) - pushLine(Wd.events.woke(store.state.pet!.name)); - // An offer left open when the page closed has gone stale. - if (store.state.offer) store.state.offer = null; - } - store.state.lastTick = now; - growUp(); - save(); -} - -export function growUp() { - if (!store.state.pet) return; - const st = stageFor(age()); - if (st !== store.state.pet!.stage) { - store.state.pet!.stage = st; - pushLine(Wd.events.grew(store.state.pet!.name, st)); - toast(`${store.state.pet!.name} grew into a ${st}!`); - wantTurn(); - } + petEngine().advance(now, true); + lastSecond = now; + changed(); } let lastSecond = Date.now(); +/** Restarts the body clock so time spent elsewhere (on a host) is not replayed. */ export function resetSimulationClock() { lastSecond = Date.now(); + petEngine().resetClock(lastSecond); } export function tick() { if (!canRunLocally()) return; const now = Date.now(); if (now - lastSecond < 1000) return; - const evs = simulate(lastSecond, now, false); lastSecond = now; - store.state.lastTick = now; - if (!store.state.pet) return; - rollDay(); - growUp(); - if (evs.includes("pooped")) { - pushLine(Wd.events.pooped(store.state.pet!.name)); - logToday("pooped"); - wantTurn(); - } - if (evs.includes("sick")) toast(`${store.state.pet!.name} is feeling sick. Medicine helps.`); - if (evs.includes("fell asleep")) { - logToday("napped"); - onSleep(); - } - if (evs.includes("woke")) { - resetDisturbances(); - view.dream = null; - pushLine(Wd.events.woke(store.state.pet!.name)); - wantTurn(); - } - if (store.state.stats.tummy < 15 && !store.state.today.log.includes("was very hungry")) - logToday("was very hungry"); - if (store.state.stats.clean < 20 && !store.state.today.log.includes("was stinky")) - logToday("was stinky"); - maybeIdle(); - maybeDiary(); - emit(); - save(); + changed(petEngine().advance(now)); + pump(); } diff --git a/web/app/storage.ts b/web/app/storage.ts index 01fc979..8ef0027 100644 --- a/web/app/storage.ts +++ b/web/app/storage.ts @@ -1,4 +1,5 @@ import type { PetState } from "../types.ts"; +import { petEngine } from "./engine.ts"; import { fresh, parseSave } from "./save-format.ts"; import { store } from "./store.ts"; import { errorMessage } from "./utils.ts"; @@ -17,7 +18,7 @@ export function load(): PetState { } catch (e) { store.badEgg = { raw, - why: e instanceof SyntaxError ? "it is damaged" : errorMessage(e), + why: errorMessage(e), newer: e instanceof Error && "newer" in e && !!e.newer, }; return fresh(); @@ -32,7 +33,7 @@ export function save() { saveTimer = setTimeout(() => { if (store.resetting || store.badEgg || !store.tabOwned) return; try { - localStorage.setItem(KEY, JSON.stringify(store.state)); + localStorage.setItem(KEY, petEngine().stateJson()); } catch { /* quota or private mode */ } @@ -49,5 +50,5 @@ 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)); + localStorage.setItem(KEY, petEngine().stateJson()); } diff --git a/web/app/store.ts b/web/app/store.ts index 4f16b74..8b662e6 100644 --- a/web/app/store.ts +++ b/web/app/store.ts @@ -1,8 +1,8 @@ -import { fresh } from "./save-format.ts"; import type { BrainState, Generated, Generation, PetState, View } from "../types.ts"; -// The body owns mutable simulation state; Preact subscribes to revisions. -// Animation frames update `view` without rendering the component tree at 60 Hz. +// `state` is a read-only snapshot of the pet (see engine.ts), replaced after +// every change; Preact subscribes to revisions. Animation frames update `view` +// without rendering the component tree at 60 Hz. export const store: { state: PetState; badEgg: { raw: string; why: string; newer: boolean } | null; @@ -10,7 +10,7 @@ export const store: { resetting: boolean; executionRevision: number; } = { - state: fresh(), + state: undefined as unknown as PetState, // set by boot() before rendering badEgg: null, tabOwned: false, resetting: false, diff --git a/web/app/text.ts b/web/app/text.ts index aeb0629..1377df8 100644 --- a/web/app/text.ts +++ b/web/app/text.ts @@ -1,11 +1,14 @@ -import * as Wd from "../world.ts"; +import { dreamThing, L, tags } from "../core.ts"; import { WANT_ICON } from "./config.ts"; +export { cleanText, isSilent } from "../core.ts"; +// How the chat shows the pet's tags: small "tool" chips, or a short action +// for a silent line. Parsing and the closed lists come from the shared rules. export function chipsFor(text: string) { const chips: [string | null, string, string?][] = []; - for (const m of text.matchAll(Wd.TAG_RE)) { - const [, name, arg, res] = m; - const r = res ? res.slice(1, -1) : ""; + for (const { name, arg: a, result } of tags(text)) { + const arg = a ?? "", + r = result ?? ""; if (name === "clock") chips.push(["checked the clock", r]); else if (name === "look") chips.push(["looked around", r]); else if (name === "recall") chips.push([`tried to remember "${arg}"`, r]); @@ -14,8 +17,8 @@ export function chipsFor(text: string) { else if (name === "gift") chips.push(["gave you a gift", arg, arg]); else if (name === "want") chips.push(["wants", arg, WANT_ICON[arg]]); else if (name === "diary") chips.push(["jotted in the diary", arg]); - else if (name === "dream" && Wd.DREAMS.includes(arg)) - chips.push([`dreams of ${Wd.dreamThing(arg)}`, "", arg]); + else if (name === "dream" && L.DREAMS.includes(arg)) + chips.push([`dreams of ${dreamThing(arg)}`, "", arg]); else if (name === "sleep") chips.push(["fell asleep", ""]); else if (name === "yes") chips.push(["said yes", ""]); else if (name === "no") chips.push(["said no", ""]); @@ -40,14 +43,3 @@ export const ACT_WORD: Record = { "walk-left": "toddles left", "walk-right": "toddles right", }; -export function cleanText(text: string) { - return text - .replace(Wd.TAG_RE, " ") - .replace(/[\[\]{}]/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -// GPT-2 learned from the open web; keep the pet's mouth clean regardless. -export const NOT_CUTE = - /\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/i; diff --git a/web/app/utils.ts b/web/app/utils.ts index f76ced6..1c8dbc3 100644 --- a/web/app/utils.ts +++ b/web/app/utils.ts @@ -6,15 +6,11 @@ export const pick = (xs: readonly T[]): T => xs[Math.floor(Math.random() * xs export const rand = (a: number, b: number) => a + Math.random() * (b - a); export const fmtMB = (n: number) => `${Math.round(n / 1e6)} MB`; export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); +/** A local date for download file names. */ export function dateKey(t: number) { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } -export const cleanName = (value: string, max: number) => - value - .trim() - .replace(/[:*\[\]{}\n]/g, "") - .slice(0, max); export function download(raw: string, name: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([raw], { type: "application/json" })); diff --git a/web/brain.ts b/web/brain.ts index 39a51c0..62ba7ca 100644 --- a/web/brain.ts +++ b/web/brain.ts @@ -1,3 +1,4 @@ +import { fit, initCore, stop } from "./core.ts"; import type { Model, Progress, Tokenizer, Transformers } from "./runtime-types.ts"; import type { Generation, WorkerRequest, WorkerResponse } from "./types.ts"; // Tokenling brain: a module worker that runs the fine-tuned GPT-2 (small, 4-bit) @@ -8,7 +9,6 @@ let T: Transformers, tokenizer: Tokenizer, model: Model, busy = false; -const CTX = 1024; const post = ( type: K, @@ -22,6 +22,8 @@ async function load({ dtype, threads, }: Extract) { + // Stop rules and context fitting come from the shared Rust rules (core/). + await initCore(); T = await import(`${runtime}/transformers.min.js`); if (threads) T.env.backends.onnx.wasm.numThreads = threads; T.env.allowRemoteModels = false; @@ -62,10 +64,8 @@ async function load({ } // Stop at end of line (chat) or right after a lookup tag so the app can -// append its {result}; diaries run until <|endoftext|>. -// GPT-2 has a single "][" token, so a lookup can close mid-token. -const LOOKUP_END = /\[(clock|look|yesterday|recall:[^\]\n]{1,80})\]\[?$/; - +// append its {result}; diaries stop at a new header. The rule is shared with +// the host's native brain (core/src/world.rs `stop`). function makeStopper( promptLen: number, mode: Generation["mode"], @@ -74,56 +74,22 @@ function makeStopper( class Stop extends T.StoppingCriteria { _call(input_ids: (number | bigint)[][]) { const ids = input_ids[0].slice(promptLen).map(Number); - const text = tokenizer.decode(ids, { skip_special_tokens: true }); - state.text = text; - if (mode === "line") { - if (text.includes("\n")) { - state.reason = "line"; - return [true]; - } - if (LOOKUP_END.test(text)) { - state.reason = "lookup"; - return [true]; - } - } else if (/\n##|\n\n\n/.test(text)) { - state.reason = "end"; - return [true]; - } - return [false]; + state.text = tokenizer.decode(ids, { skip_special_tokens: true }); + const stopped = stop(mode, state.text); + if (!stopped) return [false]; + state.reason = stopped.reason; + state.text = stopped.text; + return [true]; } } return new Stop(); } -// lines: newest last. Drops the oldest lines until header+lines+prefix fit in -// ctx tokens (prompt plus new tokens). Idle turns pass a smaller ctx because -// prefill dominates their cost. -function fit(header: string, lines: string[], prefix: string, maxNew: number, ctx = CTX) { - const budget = Math.min(ctx, CTX) - maxNew - 4; - const count = (s: string) => tokenizer.encode(s).length; - const hTok = count(header) + count(prefix); - const kept = []; - let used = hTok; - for (let i = lines.length - 1; i >= 0; i--) { - const n = count(lines[i] + "\n"); - if (used + n > budget) break; - kept.unshift(lines[i]); - used += n; - } - return header + kept.map((l) => l + "\n").join("") + prefix; -} - -async function generate({ - id, - header, - lines, - prefix, - mode, - maxNew, - temperature, - ctx, -}: Extract) { - const prompt = fit(header, lines, prefix, maxNew, ctx); +async function generate(request: Extract) { + const { id, mode, maxNew, temperature, topK, topP } = request; + // Drops the oldest chat lines until the prompt fits; idle turns pass a + // smaller ctx because prefill dominates their cost. + const prompt = fit(request, (text) => tokenizer.encode(text).length); const inputs = tokenizer(prompt); const promptLen = inputs.input_ids.dims[1]; const state = { text: "", reason: "length" }; @@ -141,16 +107,12 @@ async function generate({ max_new_tokens: maxNew, do_sample: true, temperature, - top_k: 40, - top_p: 0.92, + top_k: topK, + top_p: topP, stopping_criteria: [makeStopper(promptLen, mode, state)], streamer, }); - let text = state.text; - if (state.reason === "line") text = text.slice(0, text.indexOf("\n")); - if (state.reason === "lookup") text = text.replace(/\]\[$/, "]"); - if (mode !== "line") - text = text.replace(/\n##[\s\S]*$/, "").replace(/<\|endoftext\|>[\s\S]*$/, ""); + const text = state.text; // prefillMs: prompt processing up to the first new token; tps: decoding speed after it. const end = performance.now(), n = tokenizer.encode(text).length; diff --git a/web/components/Chat.tsx b/web/components/Chat.tsx index f578a85..dec9184 100644 --- a/web/components/Chat.tsx +++ b/web/components/Chat.tsx @@ -1,9 +1,8 @@ import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { sendMessage } from "../app/commands.ts"; -import { isSilent } from "../app/conversation.ts"; import { getTip } from "../app/pwa.ts"; import { brain, store, ui } from "../app/store.ts"; -import { ACT_WORD, chipsFor, cleanText } from "../app/text.ts"; +import { ACT_WORD, chipsFor, cleanText, isSilent } from "../app/text.ts"; import { Icon } from "./shared.tsx"; import { isRemote } from "../app/hosting.ts"; export function Chat({ active }: { active: boolean }) { diff --git a/web/components/Debug.tsx b/web/components/Debug.tsx index 2b18f54..722c3f3 100644 --- a/web/components/Debug.tsx +++ b/web/components/Debug.tsx @@ -1,20 +1,20 @@ import { useState } from "preact/hooks"; -import { disturbanceCount } from "../app/animation.ts"; -import { offerThing } from "../app/care.ts"; -import { DAY, MIN, STAGE_AGE, STATS } from "../app/config.ts"; +import { DAY, MIN, STATS } from "../app/config.ts"; import { copyText, setAge } from "../app/debug.ts"; import { writeDiary } from "../app/diary.ts"; +import { editState, petEngine } from "../app/engine.ts"; import { age, dayNum, worldState } from "../app/simulation.ts"; -import { save } from "../app/storage.ts"; -import { brain, emit, store, ui, view } from "../app/store.ts"; +import { brain, store, ui, view } from "../app/store.ts"; +import { header, offerThing, stageAges, wakeChance } from "../core.ts"; import type { Disturbance } from "../types.ts"; -import { header, wakeChance } from "../world.ts"; import { canRunLocally, isRemote } from "../app/hosting.ts"; export function Debug() { const s = store.state, pet = s.pet; const [days, setDays] = useState(() => (age() / DAY).toFixed(2)); - const n = disturbanceCount() + 1; + // Disturbances reset after five quiet minutes, as the engine counts them. + const d = petEngine().disturbances; + const n = (Date.now() - d.last >= 5 * MIN ? 0 : d.count) + 1; return (

{`brain ${brain.id || "none"} · ${pet.stage}, day ${dayNum()} (${(age() / DAY).toFixed(2)} days old) · offer open: ${s.offer ? offerThing(s.offer) : "none"}`}

- {STAGE_AGE.slice() + {stageAges() .reverse() .map(([stage, ms]) => (