From 434185e6bb03c0d16543fdf4c5f759a2da1461cc Mon Sep 17 00:00:00 2001 From: Lumi Date: Wed, 7 Oct 2026 16:43:16 -0700 Subject: [PATCH] Rewrite frontend with Preact and strict TypeScript --- .gitignore | 2 + AGENTS.md | 41 +- README.md | 25 +- docs/architecture.md | 47 +- docs/deployment.md | 15 +- docs/export-and-runtime.md | 11 +- docs/extending.md | 42 +- docs/gotchas.md | 12 +- docs/host-page.md | 46 +- docs/pet-simulation.md | 26 +- docs/prompt-format.md | 8 +- docs/testing.md | 69 +- package-lock.json | 914 +++++++++++++++++ package.json | 26 + site/build.sh | 12 +- site/bundle.mjs | 21 + site/index.html | 138 +-- test/bench.html | 2 +- test/body.test.mjs | 62 ++ test/browser-flow.js | 189 ++++ test/contract.mjs | 68 +- test/fixtures/save-v1.json | 56 ++ test/parity.mjs | 4 +- test/storage.test.mjs | 61 ++ train/check_onnx.py | 2 +- train/quant_eval.py | 2 +- train/world.py | 6 +- tsconfig.json | 17 + web/app/animation.ts | 99 ++ web/app/body.ts | 57 ++ web/app/brain.ts | 293 ++++++ web/app/canvas.ts | 136 +++ web/app/care.ts | 178 ++++ web/app/commands.ts | 136 +++ web/app/config.ts | 51 + web/app/conversation.ts | 219 +++++ web/app/debug.ts | 44 + web/app/diary.ts | 119 +++ web/app/game.ts | 91 ++ web/app/lifecycle.ts | 93 ++ web/app/pwa.ts | 199 ++++ web/app/save-format.ts | 60 ++ web/app/simulation.ts | 192 ++++ web/app/storage.ts | 46 + web/app/store.ts | 99 ++ web/app/text.ts | 53 + web/app/utils.ts | 24 + web/{brain.js => brain.ts} | 116 ++- web/components/App.tsx | 108 ++ web/components/Brain.tsx | 105 ++ web/components/Chat.tsx | 128 +++ web/components/Debug.tsx | 161 +++ web/components/Device.tsx | 179 ++++ web/components/Diary.tsx | 47 + web/components/Settings.tsx | 143 +++ web/components/Setup.tsx | 120 +++ web/components/Shelf.tsx | 46 + web/components/shared.tsx | 33 + web/main.tsx | 11 + web/pet.js | 1681 -------------------------------- web/runtime-types.ts | 66 ++ web/scene.js | 352 ------- web/scene.ts | 541 ++++++++++ web/{sprites.js => sprites.ts} | 107 +- web/types.ts | 220 +++++ web/{world.js => world.ts} | 182 +++- 66 files changed, 6058 insertions(+), 2401 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 site/bundle.mjs create mode 100644 test/body.test.mjs create mode 100644 test/browser-flow.js create mode 100644 test/fixtures/save-v1.json create mode 100644 test/storage.test.mjs create mode 100644 tsconfig.json create mode 100644 web/app/animation.ts create mode 100644 web/app/body.ts create mode 100644 web/app/brain.ts create mode 100644 web/app/canvas.ts create mode 100644 web/app/care.ts create mode 100644 web/app/commands.ts create mode 100644 web/app/config.ts create mode 100644 web/app/conversation.ts create mode 100644 web/app/debug.ts create mode 100644 web/app/diary.ts create mode 100644 web/app/game.ts create mode 100644 web/app/lifecycle.ts create mode 100644 web/app/pwa.ts create mode 100644 web/app/save-format.ts create mode 100644 web/app/simulation.ts create mode 100644 web/app/storage.ts create mode 100644 web/app/store.ts create mode 100644 web/app/text.ts create mode 100644 web/app/utils.ts rename web/{brain.js => brain.ts} (58%) create mode 100644 web/components/App.tsx create mode 100644 web/components/Brain.tsx create mode 100644 web/components/Chat.tsx create mode 100644 web/components/Debug.tsx create mode 100644 web/components/Device.tsx create mode 100644 web/components/Diary.tsx create mode 100644 web/components/Settings.tsx create mode 100644 web/components/Setup.tsx create mode 100644 web/components/Shelf.tsx create mode 100644 web/components/shared.tsx create mode 100644 web/main.tsx delete mode 100644 web/pet.js create mode 100644 web/runtime-types.ts delete mode 100644 web/scene.js create mode 100644 web/scene.ts rename web/{sprites.js => sprites.ts} (67%) create mode 100644 web/types.ts rename web/{world.js => world.ts} (54%) diff --git a/.gitignore b/.gitignore index 90317cc..d1d3f00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ .playwright-cli/ logs/ +node_modules/ +dist/ diff --git a/AGENTS.md b/AGENTS.md index 2cf0861..d4d6e81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,12 +12,12 @@ Read the area document before editing a subsystem. | Area | Documentation | Primary source | |---|---|---| -| System overview, data flow | `docs/architecture.md` | `web/pet.js`, `web/brain.js` | -| Prompt header, tool grammar, lookups | `docs/prompt-format.md` | `train/world.py`, `web/world.js` | -| Pet simulation, storage, UI | `docs/pet-simulation.md` | `web/pet.js`, `web/scene.js`, `web/sprites.js` | -| The page and embedding the app | `docs/host-page.md` | `site/index.html`, `web/pet.js` | +| System overview, data flow | `docs/architecture.md` | `web/main.tsx`, `web/app/`, `web/brain.ts` | +| Prompt header, tool grammar, lookups | `docs/prompt-format.md` | `train/world.py`, `web/world.ts` | +| Pet simulation, storage, UI | `docs/pet-simulation.md` | `web/app/`, `web/components/`, `web/scene.ts`, `web/sprites.ts` | +| The page and embedding the app | `docs/host-page.md` | `site/index.html`, `web/components/` | | Teacher data, training, evals | `docs/training.md` | `train/gen_data.py`, `train/mix_data.py`, `train/train.py`, `train/eval_pet.py` | -| ONNX export, quantization, runtime | `docs/export-and-runtime.md` | `train/export.sh`, `train/quantize.py`, `web/brain.js` | +| ONNX export, quantization, runtime | `docs/export-and-runtime.md` | `train/export.sh`, `train/quantize.py`, `web/brain.ts` | | Publishing, server requirements, GPU etiquette | `docs/deployment.md` | `train/publish.sh`, `train/vendor.sh` | | Tests and browser checks | `docs/testing.md` | `test/`, `train/eval_pet.py`, `train/quant_eval.py` | | Adding a tool, emote, food or stage | `docs/extending.md` | all of the above | @@ -28,7 +28,7 @@ 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.js` render the same state header, event lines and lookup results, +`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 @@ -37,7 +37,7 @@ a new model id and publish it. Never "fix" the format in only one place. ### 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.js`. The data generator rejects anything else, +fixed lists in `world.py`/`world.ts`. 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. @@ -60,13 +60,13 @@ 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/pet.js` and +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. ### No repetition penalty transformers.js applies it across the prompt, which penalizes tag names and -brackets and corrupts tool calls. Leave it off in `web/brain.js`. +brackets and corrupts tool calls. Leave it off in `web/brain.ts`. ### Published model and runtime directories are immutable @@ -120,7 +120,7 @@ pushes the conversation out of the context. ### Saves are never overwritten blindly `load()` migrates older saves step by step (`VERSION`, `MIGRATIONS` in -`web/pet.js`) and shows the "bad egg" for anything it cannot read, including a +`web/app/save-format.ts`) 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 @@ -145,7 +145,7 @@ body 4-bit and unpacks it to fp32 with constant-foldable ops at load ### Keep it cute -Pet text is filtered through `NOT_CUTE` in `web/pet.js` before display, because +Pet text is filtered through `NOT_CUTE` in `web/app/text.ts` 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. @@ -174,7 +174,13 @@ For a new model, record its run receipt (data, training, eval and quantization numbers, dates) in `docs/training.md`, and update the numbers quoted in the other docs. -Before committing, re-read the docs you touched against the code, run +Keep interactive markup and event handlers in Preact components; use refs for +canvas/input integration. Do not reintroduce DOM-building controllers. Keep +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 measured: mark anything untested as untested, and put estimates and choices @@ -197,6 +203,8 @@ doc, and test a new script against a stand-in before relying on it. ## Everyday commands ```sh +npm ci # install pinned frontend dependencies (Node.js 24+) +npm test # types, parity, UI contract, body/save and data rules node test/parity.mjs # JS and Python prompts agree $VENV/bin/python train/train.py --help # training environment (torch + CUDA) $VENV/bin/python train/eval_pet.py CHECKPOINT 40 @@ -210,14 +218,15 @@ 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 has no bundler: it is plain ES modules served as-is, and -`site/build.sh` only fills the page template and copies files. The repository +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. ## Working agreements -- Match the surrounding code's style. The browser app is dependency-free - vanilla JS; the train scripts are small standalone Python files. +- Match the surrounding code's style. The browser app uses typed Preact + components and focused controllers; the train scripts are small standalone Python files. - Test in a real browser with `playwright-cli` (see `docs/testing.md`). Capture the LCD with `playwright-cli screenshot "#pet-lcd"` when changing art or animation, and check the mobile layout at 360 px for horizontal overflow. diff --git a/README.md b/README.md index 4dfff43..627d37c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,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.js`: +`train/world.py` and `web/world.ts`: ``` ## tokenling chat @@ -76,12 +76,17 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] | Path | Contents | |---|---| -| `web/` | browser app: `pet.js` (body, offers, turn scheduler, chat/tool loop, diary), `brain.js` (model worker), `scene.js` + `sprites.js` (pixel LCD), `world.js` (prompt format) | +| `web/main.tsx`, `web/components/` | Preact entry and typed UI components: device/setup, chat, diary, shelf, settings, brain notice/dialog and debug panel | +| `web/app/` | typed state, save format/storage, body simulation, care, animations, conversation, diary, model client, game, PWA and lifecycle controllers; see the module map in `docs/architecture.md` | +| `web/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 | +| `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/release.json` | builds the static site from the release description (current and older models) | +| `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.js` | +| `train/world.py` | prompt format, tool results and event lines, mirrored by `web/world.ts` | | `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 | @@ -92,16 +97,22 @@ Mochi: [clock]{21:40 night} it's so late!! [emote:sleepy] [want:snack] | `train/check_onnx.py` | greedy-decode check of an export against torch | | `test/parity.mjs`, `test/parity_states.py` | JS/Python prompt parity | | `test/data_rules.py` | training-contract and migration regression cases, including a stand-in sleep teacher | -| `test/contract.mjs` | checks the app, `site/index.html` and `docs/host-page.md` agree on element ids and config keys | +| `test/contract.mjs` | checks the Preact component ids, host mount/config contract and docs | +| `test/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | body rules and save compatibility, including a save captured from the previous app | +| `test/browser-flow.js` | repeatable playwright-cli browser regression flow against a local debug preview | | `test/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 browser app has no bundler and no dependencies; `site/build.sh` only fills -the page's template and copies files (see [`docs/deployment.md`](docs/deployment.md)). +The frontend uses Preact and strict TypeScript. `site/build.sh` type-checks, +bundles the app and worker with esbuild, then fills the static host template. +No server is needed at runtime. See [`docs/deployment.md`](docs/deployment.md). ## Quick start ```sh +npm ci # Node.js 24 or newer +npm test # types, parity, UI contract, body/save and data rules +npm run format:check # consistent TS/TSX formatting node test/parity.mjs # JS and Python prompts agree node test/contract.mjs # app, reference page and docs agree on element ids diff --git a/docs/architecture.md b/docs/architecture.md index 7c2ebb7..d6f46da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,14 +14,13 @@ 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 web/*.js, content-hashed so modules never mix versions + ├─ app//*.js bundled main.js + brain.js, content-hashed together ├─ models// tokenizer + config + onnx/model_q4.onnx └─ runtime// transformers.min.js + ONNX Runtime Web .wasm/.mjs browser - ├─ main thread pet.js state, simulation, UI, chat/tool loop, diary - │ scene.js 96x64 canvas LCD sprites.js pixel art - │ world.js prompt header, event lines, tool results + ├─ 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 ├─ service worker sw.js serves the offline copies, shows alerts ├─ localStorage "tokenling.v1" the whole pet (JSON, see pet-simulation.md) @@ -35,20 +34,40 @@ browser | File | Role | |---|---| -| `web/pet.js` | Owns state `S`. Reads and migrates the save (or shows the bad egg), holds the one-tab lock, runs the body (1 s simulation tick), offers and care, the catch minigame, the turn scheduler (`wantTurn`/`pump`, react and idle turns), the pet's turn (`petTurn`/`petLine`), applying the brain's tags, diary writing, choosing, upgrading and cleaning up brains, install and alert plans, and the UI. Exposes `window.tokenling` for tests. | -| `web/brain.js` | Module worker. Imports transformers.js from the vendored runtime, loads tokenizer and the 4-bit model on WebAssembly (with the session options the model needs), trims history to a context budget, generates with stop criteria, and posts `token`/`done`/`error` messages. | -| `web/world.js` | Mirror of `train/world.py`: `header()`, `toolResult()`, `recall()`, `look()`, `clock()`, event-line builders and the word lists. | -| `web/scene.js` | Draws the LCD: window with weather, shelf of gifts, poop, the pet (per-stage size, decorations, face, animations), the egg, effects and the minigame. | -| `web/sprites.js` | 8x8 pixel icons as strings, canvas and `` helpers, and a 3x5 pixel font. | +| `web/main.tsx` | 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 v1 defaults/validation/migrations and guarded debounced browser persistence. | +| `web/app/body.ts`, `web/app/simulation.ts` | Pure body simulation over a supplied state, then the clock, catch-up, growth, day log and prompt state adapter. Alert projection simulates a clone. | +| `web/app/conversation.ts`, `web/app/text.ts` | React/idle turn scheduler, lookup continuations, stale replies and tag effects; text filtering and chat chip descriptions. | +| `web/app/care.ts`, `web/app/diary.ts` | Hatching, care offers/answers and bedtime/missed diary pages. | +| `web/app/animation.ts`, `web/app/canvas.ts`, `web/app/game.ts` | Animation state, disturbances, canvas attachment/cleanup and catch physics/input. | +| `web/app/brain.ts` | Model cache, download, upgrade, worker protocol and one-thread fallback. | +| `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/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/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/browser-flow.js` | Repeatable playwright-cli flow for a local debug preview with a real brain. | | `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 and migration; includes a stand-in sleep teacher. | | `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`. | -There is no bundler; `site/build.sh` only fills the template and copies files. The page (`site/index.html`) loads `pet.js` as -` - + ``` | Config key | Meaning | @@ -89,23 +90,23 @@ key that the page or this document does not cover. | `icon` | optional URL of the icon shown on alerts | | `weatherSeed` | optional string mixed into the deterministic weather | -`web/*.js` must be served together from one directory: `pet.js` imports -`world.js`, `scene.js` and `sprites.js` by relative path and starts -`brain.js` as a module worker relative to itself. Put them under a versioned -or hashed path so a visitor never mixes versions of the modules. Do not load -the app from a different origin than the page, the model or the runtime (see -"Isolation" below). +`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. 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 explanatory content visible. -## Required elements +## Host elements and component ids -The app finds elements by id and does not create the page structure. A missing -id breaks the feature that uses it, and usually the whole app at boot. The app toggles the `hidden` attribute, so the page's CSS -must keep `[hidden] { display: none }` winning over any `display: flex/grid` -rules. +The host provides only `pet-root` (an empty mount element) and `pet-config` +(the JSON block). Components in `web/components/` create and update all the ids +below; embedding pages must not duplicate their markup. The ids remain useful +for browser tests, labels and accessibility. CSS must keep `[hidden] { display: +none }` winning over grid/flex rules. | Area | Ids | |---|---| @@ -123,14 +124,16 @@ rules. | Notifications | `pet-toasts` | | Config | `pet-config` (the JSON block above) | -`pet.js` creates `act-feed` and `diary-draft` itself; do not add them. With +Care buttons are `act-feed`, `act-play`, `act-bath`, `act-pat`, `act-clean`, +`act-meds`, `act-lights`; the streamed diary uses `diary-draft`. With `?debug=1` (or `#debug`) in the URL it also adds a debug tab after the last tab (`tab-debug`, `panel-debug`, `debug-age`); see `docs/testing.md`. ### Tabs Tab buttons are `role="tab"` with ids `tab-chat`, `tab-diary`, `tab-shelf`, -`tab-care`, each with `aria-controls` naming a `role="tabpanel"` element; all +`tab-care`, each with `aria-controls` naming `panel-chat`, `panel-diary`, `panel-shelf` or +`panel-care` (`role="tabpanel"`); all panels except the first start `hidden`. The app handles clicks, arrow keys, `aria-selected`, `tabindex` and showing and hiding the panels. @@ -163,11 +166,12 @@ none` and disables text selection so holding a direction stays a game input. A brain can be trained for something older brains cannot do. List the feature's name in the config (`features`, and in each `older` entry that has -it) and mark controls that need it with `data-needs="name"`. While an older -brain without that feature runs, the app disables those controls, gives them +it). In components, use `brainHas(name)` to set disabled/class/title props, +with `data-needs="name"` for inspection. While an older brain without that +feature runs, the component disables those controls, gives them the class `needs-brain` and a "needs the new brain" title, and the upgrade -dialog lists what the new brain adds (`FEATURE_TEXT` in `pet.js`). Care -actions are gated by `ACT_NEEDS` in `pet.js`. No feature is gated yet. +dialog lists what the new brain adds (`FEATURE_TEXT` in `web/app/config.ts`). Care +actions are gated by `ACT_NEEDS` in `web/app/config.ts`. No feature is gated yet. ## Installing, offline use and alerts diff --git a/docs/pet-simulation.md b/docs/pet-simulation.md index 50d623f..a96971d 100644 --- a/docs/pet-simulation.md +++ b/docs/pet-simulation.md @@ -1,8 +1,10 @@ # Pet simulation, storage and UI -All of this lives in `web/pet.js` (rules and state), `web/scene.js` (LCD) and -`web/sprites.js` (art). Times are real wall-clock time; the pet keeps aging -while the tab is closed. +The typed controllers live in `web/app/`; body math is in `body.ts`, the clock +and logs in `simulation.ts`, and care/turns/diary/game in their own modules. +Preact components in `web/components/` render the UI. `web/scene.ts` and +`web/sprites.ts` draw the LCD. Times are real wall-clock time; the pet keeps +aging while the tab is closed. The split is strict: **the brain decides, the app runs the body.** Stats, poop, sickness, collapsing from exhaustion and waking up rested are @@ -169,7 +171,7 @@ localStorage key `tokenling.v1`, JSON. Saves are debounced by 200 ms and failures (private mode, quota) are ignored. ``` -v: 1 save version (VERSION in pet.js) +v: 1 save version (VERSION in web/app/save-format.ts) created, lastTick, lastActive, grimeSince, nextPoop ms timestamps owner name the pet calls them egg: { laid } | null @@ -195,7 +197,7 @@ 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 `pet.js`. +`VERSION` and adding a step to `MIGRATIONS` in `web/app/save-format.ts`. ### Save versions and the bad egg @@ -209,7 +211,9 @@ newer save, since the page may be out of date), or start anew, which moves the raw save to `tokenling.bad` (replacing an older one there) and starts a fresh egg. While the bad egg is shown nothing is saved. Imports go through the same `parseSave()`, so an imported older save is migrated and a newer one is -refused with the reason. +refused with the reason. An accepted import writes the validated save and +reloads under the Web Lock, cancelling the previous pet's worker, timers and +queued turns before resuming. Save version 1 is unchanged by the Preact rewrite. ### One tab at a time @@ -218,7 +222,11 @@ for a Web Lock named after the save key (`ifAvailable`). A tab that does not get it shows "Your pet is open in another tab" and never loads, ticks or saves the pet; "use it here" reloads that tab and steals the lock. The tab that lost the lock stops at once (no more saves, brain stopped) and shows the same -notice. Browsers without Web Locks run every tab as before. +notice. Page exit explicitly releases the lock; a new document retries an +unavailable lock twice at 100 ms intervals to allow the previous document to +finish releasing it, without stealing. A page restored from the back/forward +cache reloads to reclaim ownership before resuming. Browsers without Web Locks +run every tab as before. Export, import and start over are on the "settings" tab. Start over is a hard reset: it removes the `tokenling.v1` key synchronously, blocks further saves, @@ -270,7 +278,7 @@ one; not tested on a phone, and periodic sync was not observed firing. ## Chat rendering -`lineNode()` shows each pet line as plain text with the tags removed. +`ChatLine` in `web/components/Chat.tsx` shows each pet line as plain text with the tags removed. Emotes and walking become an italic `*giggles, toddles left*`; tool calls become small chips under the line ("looked around …", "saved a memory …", "gave you a gift …", "said yes") using the tool's arguments and results. A @@ -288,5 +296,5 @@ the pet or egg, an offered item, held food, floating effects and the want or "thinking" bubble, or a dream bubble while asleep. The pet only moves toward a target set by `[walk:…]` (or the minigame). `drawPet()` varies size, decorations and face per stage and animation (idle, happy, dance, -spin, eat, pat, sleepy and so on). Icons are `SPRITES` in `sprites.js`: rows of +spin, eat, pat, sleepy and so on). Icons are `SPRITES` in `sprites.ts`: rows of characters, one per pixel, using the palette in `KEY`. diff --git a/docs/prompt-format.md b/docs/prompt-format.md index f6603e6..aba265a 100644 --- a/docs/prompt-format.md +++ b/docs/prompt-format.md @@ -1,7 +1,7 @@ # Prompt format and tool grammar This is the contract between the app and the weights. `train/world.py` renders -it for training and `web/world.js` for the browser; `node test/parity.mjs` +it for training and `web/world.ts` for the browser; `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 @@ -71,7 +71,7 @@ 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.js:events` +`world.py:event_patterns()` is the validator for training data; `world.ts:events` builds them. Foods take "a" or "an" (`a(food)`). Messages and pats may wake a sleeping pet by a body roll; a successful roll @@ -142,7 +142,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.js` trims), +stops after the closing `]` (or the single token `][`, which `brain.ts` 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. @@ -158,7 +158,7 @@ matches on any shared word. 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.js` drops the oldest chat lines until header + 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). diff --git a/docs/testing.md b/docs/testing.md index b556e61..3b33d70 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,25 +2,79 @@ | Check | Command | Catches | |---|---|---| +| Type checking | `npm run typecheck` | strict checking of every TS/TSX component, controller, canvas renderer and worker | +| Body and saves | `node --test test/*.test.mjs` | simulation rules, save v1 compatibility, bad eggs and ownership/reset write guards | | Prompt parity | `node test/parity.mjs` | JS/Python drift in headers, stat words, word lists, look/clock/recall, event lines and offers | | 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 transitions, offers, header filtering, memory and diary rules, migration masks and a stand-in sleep teacher | -| Page contract | `node test/contract.mjs` | element ids or config keys the app uses but `site/index.html` or `host-page.md` lacks | +| Page contract | `node test/contract.mjs` | host mount/config, component ids, documented config keys and accidental imperative rendering | | Site build | `site/build.sh /tmp/site-out` | unfilled placeholders, a config block that is not valid JSON | | Brain speed and memory | `/bench.html` in every site build | load time, decode tokens/s, prefill time, page memory | | Browser flow | `playwright-cli` against `test/dev-site.sh` or the live URL | everything the visitor sees | -There is no unit-test runner for the browser code; the app is checked by -driving a real browser with `playwright-cli` (run `playwright-cli --help` or -see the skill for the full command list). +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 and Python +data rules. Node executes the erasable TypeScript in the pure modules directly; +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 +persistent profile**: + +```sh +playwright-cli -s=pet open http://127.0.0.1:8765/pet/?debug=1 --profile=/tmp/pet-test-profile +playwright-cli -s=pet run-code --filename=test/browser-flow.js +``` + +It checks edited/cleared hatch names, chat, care offers, healthy medicine, +shelf/memory rendering, real diary generation, catch pointer/keyboard controls, +tab keys, all panels at 1280/768/360 px, cached reload and Web Lock takeover. +The shelf/gift and catch setup use explicit debug tags to make UI assertions +independent of model sampling. Use only a test profile: it creates a pet and +changes its state. Error recovery, imports, offline use and upgrades have +additional recipes below. + +`test/fixtures/save-v1.json` is an actual browser export from the previous +vanilla app at commit `6c99c23`, captured with generic Sam/Mochi names on +2026-10-07. The save tests +require it to round-trip without changing any values; this rewrite keeps v1. + +## Preact migration check receipt (2026-10-07) + +Measured in headless desktop Chromium against a local build, with the real v6 +model and vendored runtime; these are functional checks, not new speed results. + +- `npm test`: strict types, 300 parity states/15 lists with zero mismatches, + component/host contract, eight body/save tests and ten data-rule tests passed. + `npm run format:check`, clean `npm ci`, root and subpath builds also passed. +- `test/browser-flow.js` passed: fresh download/hatch, chat, food and healthy + medicine offers, memory/shelf, real diary generation, pointer/keyboard catch, + all panels at 1280/768/360 px, cached reload and tab takeover. The regular + flow had no browser console errors or warnings. LCD screenshot inspected. +- Loaded a save from the previous vanilla app; name, owner and transcript + survived. A v99 save stayed untouched after rendering/ticking. Valid save + import, export and hard reset worked in a disposable profile. +- A deliberately missing worker script showed the retry control at 360 px; + retry loaded the real brain and preserved the egg and edited name. +- Upgrade dialog/download/swap/cache cleanup passed using two local aliases of + the same v6 export (a stand-in for two compatible releases). The pet survived + and only the selected model's files remained. This does not establish v5/v6 + prompt compatibility; the release's `older` list remains empty. +- With the preview server stopped, reload loaded the cached page, both app + bundles, runtime and brain; isolation stayed enabled and a real chat reply + completed. Static explanations/build details were visible with JavaScript off. +- Not re-tested for this rewrite: physical phones, Firefox's throttled download, + actual install prompts, background alert delivery, or the 90-second + one-thread fallback. Their existing logic remains in the typed controllers. ## Preview server ```sh export MODEL=$DATA/release/tokenling-v6 RUNTIME=$DATA/release/runtime # from export.sh and vendor.sh test/dev-site.sh /tmp/dev-site & # stages and serves http://127.0.0.1:8765/pet/ -test/dev-site.sh /tmp/dev-site --rebuild # after editing web/*.js or the page +test/dev-site.sh /tmp/dev-site --rebuild # after editing web/ TypeScript/components or the page ``` The script writes a release description for the preview and runs @@ -192,7 +246,8 @@ For the v5 → v6 format change, stage v6 with no `OLDER` instead: the saved pet must doze until v6 is downloaded. Check that its name, memories, chat and diary survive the download, and that cached v5 is never loaded by the v6 app. -**Feature gating:** add `data-needs="x"` to a control, set +**Feature gating:** give a component a `brainHas("x")` disabled/class/title +gate and `data-needs="x"` for inspection, set `window.tokenling.brain.id` to an older id that lacks `x`, call `window.tokenling.renderAll()`, and the control is disabled with the class `needs-brain`; set it back and it is enabled again. @@ -242,7 +297,7 @@ directories running. brain loader. - **Brain loading:** fresh profile (the egg's download), cached reload, and `bench.html` for speed and memory after any change to `quantize.py` or the - session options in `brain.js`. + session options in `brain.ts`. - **After deploy:** the same flow against the live URL with a fresh persistent profile, plus the live checks in `deployment.md`. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9c8a2f7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,914 @@ +{ + "name": "tokenling", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tokenling", + "dependencies": { + "preact": "11.0.0" + }, + "devDependencies": { + "esbuild": "0.28.2", + "prettier": "3.9.9", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/preact": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0.tgz", + "integrity": "sha512-LRTLKuSe6VYPhvBDjwFaX+nuZnpsw6IN2GSboisAvhGsncylBmrbDCERhgKDn6yP2mAwbGvsB5OVyTe33M8gBg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=6.7.0" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..427e848 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "tokenling", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "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", + "format": "prettier --write \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js package.json tsconfig.json", + "format:check": "prettier --check \"web/**/*.{ts,tsx}\" site/bundle.mjs test/contract.mjs test/*.test.mjs test/browser-flow.js package.json tsconfig.json" + }, + "dependencies": { + "preact": "11.0.0" + }, + "devDependencies": { + "esbuild": "0.28.2", + "prettier": "3.9.9", + "typescript": "7.0.2" + }, + "prettier": { + "printWidth": 100 + } +} diff --git a/site/build.sh b/site/build.sh index 41dc4ad..ad094fc 100755 --- a/site/build.sh +++ b/site/build.sh @@ -27,12 +27,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; } -# App modules go under a content-hashed path so a browser never mixes versions. -VER=$(cat "$REPO"/web/*.js | sha256sum | cut -c1-12) +# Type-check and bundle before replacing output. Hash emitted bytes, including +# the bundled Preact dependency, so a browser never mixes app/worker versions. +[ -x "$REPO/node_modules/.bin/tsc" ] || { echo "Run npm ci before building the site" >&2; exit 2; } +(cd "$REPO" && npm run typecheck) +BUNDLE=$(mktemp -d) +trap 'rm -rf "$BUNDLE"' EXIT +node "$HERE/bundle.mjs" "$BUNDLE" +VER=$(cat "$BUNDLE"/*.js "$BUNDLE"/*.map | sha256sum | cut -c1-12) mkdir -p "$OUT" find "$OUT" -mindepth 1 -maxdepth 1 ! -name models ! -name runtime -exec rm -rf {} + mkdir -p "$OUT/app/$VER" "$OUT/assets" -cp "$REPO"/web/*.js "$OUT/app/$VER/" +cp "$BUNDLE"/* "$OUT/app/$VER/" cp "$HERE"/assets/* "$OUT/assets/" cp "$HERE/sw.js" "$OUT/sw.js" diff --git a/site/bundle.mjs b/site/bundle.mjs new file mode 100644 index 0000000..889a1ce --- /dev/null +++ b/site/bundle.mjs @@ -0,0 +1,21 @@ +// Bundle the typed Preact app and its standalone inference worker. +// Usage: node site/bundle.mjs OUT_DIR. No environment variables are read. +import { build } from "esbuild"; +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"); +await build({ + absWorkingDir: root, + entryPoints: { main: "web/main.tsx", brain: "web/brain.ts" }, + outdir: resolve(process.argv[2]), + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + minify: true, + sourcemap: true, + legalComments: "eof", + // No split chunks: the offline resource manifest is main.js plus brain.js. + splitting: false, +}); diff --git a/site/index.html b/site/index.html index 9375714..ee15631 100644 --- a/site/index.html +++ b/site/index.html @@ -73,7 +73,7 @@ .hello h1 { margin: 0; font-size: 30px; line-height: 1.1; } .hello p { margin: 2px 0 0; color: var(--muted); font-size: 15px; } - /* The app stays hidden until pet.js has booted (it adds .pet-js to ). */ + /* The app stays hidden until main.js has booted (it adds .pet-js to ). */ .pet-app { display: none; grid-template-columns: minmax(0, 400px) minmax(0, 1fr); gap: 20px; align-items: start; } .pet-js .pet-app { display: grid; } @@ -224,6 +224,12 @@ .brain-notice .primary { width: 100%; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + .debug-pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, monospace; max-height: 60vh; overflow: auto; background: var(--soft); padding: 8px; border-radius: 8px; margin: 6px 0; } + .debug-out { background: var(--accent); color: var(--accent-ink); } + .debug-grid { display: grid; gap: 4px; } + .debug-stat { display: grid; grid-template-columns: 55px 1fr 30px; gap: 8px; align-items: center; } + .debug-grid input[type=range] { width: 100%; min-width: 0; } + #panel-debug summary { cursor: pointer; overflow-wrap: anywhere; } @@ -241,125 +247,7 @@ - - -
- -
-
- - -
-

- -
- - -
-
- -
- - -
-

The egg hatches once its brain has downloaded: about @SIZE_MB@ MB, once. It stays in this browser.

-
- - -
-
- -
-
- - - - -
- -
- -
    -
    - - - -
    -
    - - - - - - -
    -
    -
    +
    @@ -390,15 +278,7 @@ "news": @NEWS@, "features": @FEATURES@, "older": @OLDER@, "sw": "sw.js", "icon": "@ASSETS@/icon-192.png"} - -

    A new brain

    -
    -
    - - -
    -
    - + @ANALYTICS@ diff --git a/test/bench.html b/test/bench.html index 42138c1..bc96869 100644 --- a/test/bench.html +++ b/test/bench.html @@ -21,7 +21,7 @@ if (q.get("threads")) T.env.backends.onnx.wasm.numThreads = +q.get("threads"); const id = q.get("model") || "@MODEL_ID@", dtype = q.get("dtype") || "q4"; const t0 = performance.now(); const tok = await T.AutoTokenizer.from_pretrained(id); -// Same session options as web/brain.js; ?qdq=1 leaves ORT's QDQ fusion on (MatMulNBits), +// Same session options as web/brain.ts; ?qdq=1 leaves ORT's QDQ fusion on (MatMulNBits), // ?prepack=1 allows weight prepacking. const session_options = q.get("qdq") ? {} : { extra: { session: { disable_quant_qdq: "1", ...(q.get("prepack") ? {} : { disable_prepacking: "1" }) } } }; const model = await T.AutoModelForCausalLM.from_pretrained(id, { device: "wasm", dtype, session_options }); diff --git a/test/body.test.mjs b/test/body.test.mjs new file mode 100644 index 0000000..9a721aa --- /dev/null +++ b/test/body.test.mjs @@ -0,0 +1,62 @@ +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"; +const noon = new Date(2026, 9, 1, 12).getTime(); +function pet() { + const state = fresh(); + state.pet = { + name: "Mochi", + traits: ["sweet", "curious"], + color: "cream", + toy: "ball", + hatched: noon, + stage: "baby", + }; + state.nextPoop = noon + 10 * HOUR; + return state; +} +test("awake and sleeping body rates remain unchanged", () => { + const awake = pet(), + 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 }); +}); +test("sleep pauses grime exposure; awake exposure resumes it", () => { + const state = pet(); + state.poop = 3; + state.grimeSince = noon - 45 * MIN; + state.asleep = true; + state.stats.energy = 10; + simulateBody(state, noon, noon + 20 * MIN, true); + 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); +}); +test("exhaustion and recent wake protection are body rules", () => { + const state = pet(); + state.stats.energy = 5; + state.wokeAt = noon; + simulateBody(state, noon, noon + MIN, false); + assert.equal(state.asleep, false); + const events = simulateBody(state, noon + MIN, noon + 6 * MIN, false); + assert.equal(state.asleep, true); + assert.ok(events.includes("fell asleep")); +}); +test("a rested pet wakes, and a projection cannot mutate the saved body", () => { + 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); + assert.equal(state.asleep, true); + assert.equal(state.stats.energy, 98); +}); diff --git a/test/browser-flow.js b/test/browser-flow.js new file mode 100644 index 0000000..2cd971d --- /dev/null +++ b/test/browser-flow.js @@ -0,0 +1,189 @@ +// Run against a disposable local preview with ?debug=1 and a fresh persistent +// profile: playwright-cli -s=pet run-code --filename=test/browser-flow.js +// Uses the real configured brain. The only forced model effects are explicitly +// marked debug checks; nothing here should run against a personal browser save. +async function browserFlow(page) { + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + const result = []; + await page.waitForFunction(() => !!window.tokenling); + check(await page.locator("#setup-owner").isVisible(), "Use a fresh test profile"); + await page.fill("#owner-name", "Sam"); + await page.click("#setup-owner button"); + await page.fill("#pet-name", ""); + await page.waitForTimeout(1100); + check((await page.inputValue("#pet-name")) === "", "Progress must not restore a cleared name"); + await page.fill("#pet-name", "Mochi"); + await page.waitForFunction(() => ["ready", "error"].includes(tokenling.brain.state), null, { + timeout: 60000, + }); + check( + await page.evaluate(() => tokenling.brain.state === "ready"), + await page.locator("#setup-hatch-note").innerText(), + ); + check((await page.inputValue("#pet-name")) === "Mochi", "Progress changed the name"); + await page.click("#hatch-btn"); + await page.waitForFunction( + () => tokenling.state().chat.some((l) => l.startsWith("Mochi:")) && !tokenling.brain.busy, + null, + { timeout: 60000 }, + ); + result.push("fresh download, edited name, hatch and real generation"); + + await page.click("#tab-care"); + await page.uncheck("#chatter-toggle"); + check((await page.inputValue("#owner-name-edit")) === "Sam", "Settings owner is stale"); + await page.click("#tab-chat"); + const before = await page.evaluate(() => tokenling.state().chat.length); + await page.fill("#chat-input", "hello little friend"); + await page.press("#chat-input", "Enter"); + await page.waitForFunction( + (n) => tokenling.state().chat.length > n + 1 && !tokenling.brain.busy, + before, + { timeout: 60000 }, + ); + check( + await page + .locator("#chat-log") + .innerText() + .then((t) => t.includes("hello little friend")), + "Missing owner message", + ); + await page.evaluate(() => { + tokenling.state().stats.tummy = 15; + tokenling.renderAll(); + }); + await page.click("#act-feed"); + await page.getByRole("button", { name: "cookie", exact: true }).click(); + await page.waitForFunction(() => !tokenling.state().offer && !tokenling.brain.busy, null, { + timeout: 60000, + }); + check( + await page.evaluate(() => + tokenling.state().today.log.some((l) => /ate a cookie|said no to a cookie/.test(l)), + ), + "Offer was not resolved", + ); + await page.click("#act-meds"); + await page.waitForFunction(() => !tokenling.state().offer && !tokenling.brain.busy, null, { + timeout: 60000, + }); + result.push("chat, food offer and healthy medicine availability"); + + // Deterministic debug effects verify UI wiring independently of model sampling. + await page.evaluate(() => { + tokenling.debug.applyTags("[remember:Sam likes frogs] [gift:flower]"); + }); + await page.click("#tab-shelf"); + check( + await page + .locator("#memory-list") + .innerText() + .then((t) => t.includes("Sam likes frogs")), + "Memory missing", + ); + check( + await page + .locator("#shelf-items") + .innerText() + .then((t) => t.includes("flower")), + "Gift missing", + ); + await page.getByRole("button", { name: "forget: Sam likes frogs" }).click(); + check( + await page.evaluate(() => !tokenling.state().memories.includes("Sam likes frogs")), + "Forget did not persist", + ); + await page.click("#tab-debug"); + await page.locator("[data-debug-diary]").click(); + await page.waitForFunction(() => tokenling.state().diary.length && !tokenling.brain.busy, null, { + timeout: 60000, + }); + await page.click("#tab-diary"); + check((await page.locator(".diary-page").count()) > 0, "Diary page missing"); + 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.applyTags("[yes]"); + }); + await page.waitForFunction(() => !!tokenling.view.game); + const x = await page.evaluate(() => tokenling.view.x); + const pad = await page.locator("#pad-right").boundingBox(); + await page.mouse.move(pad.x + pad.width / 2, pad.y + pad.height / 2); + await page.mouse.down(); + await page.waitForTimeout(200); + await page.mouse.up(); + check( + await page.evaluate((start) => tokenling.view.x > start && tokenling.view.game.dir === 0, x), + "Held pad/release failed", + ); + await page.locator("#pad-left").focus(); + await page.keyboard.down("Space"); + await page.waitForTimeout(100); + await page.keyboard.up("Space"); + check(await page.evaluate(() => tokenling.view.game.dir === 0), "Keyboard release failed"); + await page.evaluate(() => { + tokenling.view.game.timeLeft = 0; + }); + await page.waitForFunction(() => !tokenling.view.game && !tokenling.brain.busy, null, { + timeout: 60000, + }); + result.push("pointer and keyboard game controls"); + + await page.locator("#tab-chat").focus(); + await page.keyboard.press("ArrowRight"); + check( + (await page.locator("#tab-diary").getAttribute("aria-selected")) === "true", + "Arrow tab navigation failed", + ); + await page.keyboard.press("End"); + check( + (await page.locator("#tab-debug").getAttribute("aria-selected")) === "true", + "End tab navigation failed", + ); + for (const width of [1280, 768, 360]) { + await page.setViewportSize({ width, height: 900 }); + for (const id of ["chat", "diary", "shelf", "care", "debug"]) { + await page.click("#tab-" + id); + check( + await page.evaluate(() => document.documentElement.scrollWidth === innerWidth), + `Overflow in ${id} at ${width}`, + ); + } + } + result.push("keyboard tabs and all panels at 1280/768/360px"); + + await page.reload(); + await page.waitForFunction(() => window.tokenling?.brain.state === "ready", null, { + timeout: 60000, + }); + check( + await page.evaluate( + () => tokenling.state().pet.name === "Mochi" && tokenling.state().diary.length > 0, + ), + "Reload lost the pet", + ); + const other = await page.context().newPage(); + await other.goto(page.url()); + await other.locator("#pet-elsewhere").waitFor(); + await other.click("#elsewhere-here"); + await other.waitForFunction(() => window.tokenling?.brain.state === "ready", null, { + timeout: 60000, + }); + await page.locator("#pet-elsewhere").waitFor(); + check(await page.evaluate(() => tokenling.brain.worker === null), "Old tab still owns a worker"); + await other.close(); + await page.waitForFunction( + async () => !(await navigator.locks.query()).held.some((lock) => lock.name === "tokenling.v1"), + ); + await page.reload(); + await page.waitForFunction(() => window.tokenling?.brain.state === "ready", null, { + timeout: 60000, + }); + result.push("cached reload and Web Lock takeover"); + return result; +} diff --git a/test/contract.mjs b/test/contract.mjs index f3327f7..ac96ce3 100644 --- a/test/contract.mjs +++ b/test/contract.mjs @@ -1,27 +1,51 @@ -// Checks that the app, the reference host page and docs/host-page.md agree on -// which element ids and config keys the page must provide. -import { readFileSync } from "node:fs"; +// The host supplies a mount point/config. Preact owns the interactive markup. +import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("..", import.meta.url)); const read = (p) => readFileSync(root + p, "utf8"); -const js = read("web/pet.js"), page = read("site/index.html"), doc = read("docs/host-page.md"); - -// ids the script looks up, minus the ones it creates itself -const created = new Set(["act-feed", "diary-draft", "panel-debug", "debug-age"]); -const used = new Set([...js.matchAll(/\$\("#([\w-]+)"\)|getElementById\("([\w-]+)"\)|"#([\w-]+)"/g)] - .map((m) => m[1] || m[2] || m[3]).filter((id) => !created.has(id))); -used.add("pad-left"); used.add("pad-right"); -const pageIds = new Set([...page.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])); -const docIds = new Set([...doc.matchAll(/`([a-z][\w-]*)`/g)].map((m) => m[1])); -const keys = [...js.matchAll(/CFG\.(\w+)/g)].map((m) => m[1]); - +const files = readdirSync(root + "web", { recursive: true }).filter((f) => /\.tsx?$/.test(f)); +const app = files.map((f) => read("web/" + f)).join("\n"); +const components = files + .filter((f) => f.startsWith("components/")) + .map((f) => read("web/" + f)) + .join("\n"); +const page = read("site/index.html"), + doc = read("docs/host-page.md"); +const required = [...app.matchAll(/getElementById\("([\w-]+)"\)/g)].map((m) => m[1]); +const ids = new Set([...components.matchAll(/\bid="([\w-]+)"/g)].map((m) => m[1])); +// These ids are composed from closed lists in App and Device. +for (const id of ["chat", "diary", "shelf", "care", "debug"]) { + ids.add("tab-" + id); + ids.add("panel-" + id); +} +for (const id of ["feed", "play", "bath", "pat", "clean", "meds", "lights"]) ids.add("act-" + id); +ids.add("pad-left"); +ids.add("pad-right"); +const keys = new Set([...app.matchAll(/CFG\.(\w+)/g)].map((m) => m[1])); let bad = 0; -const fail = (msg) => { console.log("FAIL", msg); bad++; }; -for (const id of used) { - if (!pageIds.has(id)) fail(`reference page lacks id "${id}"`); - if (!docIds.has(id)) fail(`docs/host-page.md does not list id "${id}"`); +const check = (ok, message) => { + if (!ok) { + console.error("FAIL", message); + bad++; + } +}; +for (const id of required) { + check(page.includes(`id="${id}"`), `host lacks ${id}`); + check(doc.includes("`" + id + "`"), `docs omit host id ${id}`); +} +for (const id of ids) check(doc.includes("`" + id + "`"), `docs omit component id ${id}`); +for (const id of [...app.matchAll(/#[a-z][\w-]+/g)].map((m) => m[0].slice(1))) { + if (/^(?:pet|pad|panel|tab|chat|brain|debug)-/.test(id)) + check(ids.has(id) || required.includes(id), `selector ${id} has no component or host element`); } -for (const k of new Set(keys)) if (!doc.includes("`" + k + "`")) fail(`docs/host-page.md does not describe config key "${k}"`); -for (const t of ["tab-chat", "tab-diary", "tab-shelf", "tab-care"]) if (!pageIds.has(t)) fail(`reference page lacks ${t}`); -console.log(`${used.size} ids, ${new Set(keys).size} config keys, ${bad} problems`); -process.exit(bad ? 1 : 0); +for (const key of keys) check(doc.includes("`" + key + "`"), `docs omit config key ${key}`); +check(page.includes('src="@APP@/main.js"'), "host must load the bundled entry"); +check(!page.includes('id="pet-lcd"'), "interactive markup must be owned by Preact"); +check( + !/replaceChildren|innerHTML|document\.querySelector/.test(app), + "UI rendering must use components and refs", +); +console.log( + `${required.length} host ids, ${ids.size} component ids, ${keys.size} config keys, ${bad} problems`, +); +process.exitCode = bad ? 1 : 0; diff --git a/test/fixtures/save-v1.json b/test/fixtures/save-v1.json new file mode 100644 index 0000000..7414f51 --- /dev/null +++ b/test/fixtures/save-v1.json @@ -0,0 +1,56 @@ +{ + "v": 1, + "created": 1791414449104, + "owner": "Sam", + "pet": { + "name": "Mochi", + "traits": [ + "bouncy", + "dreamy" + ], + "color": "mint", + "toy": "ball", + "hatched": 1791414460004, + "stage": "baby" + }, + "egg": null, + "stats": { + "tummy": 69.91103111111137, + "fun": 69.93327333333339, + "energy": 79.94439444444467, + "clean": 89.9666366666667 + }, + "sick": false, + "poop": 0, + "asleep": false, + "lightsOff": false, + "memories": [], + "shelf": [], + "chat": [ + "Mochi: mimi!! [emote:dance]", + "Mochi: [walk:right]" + ], + "diary": [], + "today": { + "date": "2026-10-07", + "log": [], + "day": 1, + "weather": "windy" + }, + "history": {}, + "offer": null, + "wokeAt": 0, + "lastTick": 1791414489130, + "lastActive": 1791414449104, + "grimeSince": 0, + "nextPoop": 1791425260004, + "settings": { + "chatter": true + }, + "counts": { + "fed": 0, + "pats": 0, + "games": 0, + "baths": 0 + } +} diff --git a/test/parity.mjs b/test/parity.mjs index 6236e02..e27300b 100644 --- a/test/parity.mjs +++ b/test/parity.mjs @@ -1,7 +1,7 @@ -// Checks web/world.js renders prompts byte-identically to train/world.py. +// Checks web/world.ts renders prompts byte-identically to train/world.py. import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import * as W from "../web/world.js"; +import * as W from "../web/world.ts"; const here = fileURLToPath(new URL(".", import.meta.url)); const { cases, lists } = JSON.parse(execFileSync("python3", [here + "parity_states.py", "1"], { maxBuffer: 1 << 26 })); let bad = 0; diff --git a/test/storage.test.mjs b/test/storage.test.mjs new file mode 100644 index 0000000..d5be08b --- /dev/null +++ b/test/storage.test.mjs @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { parseSave, VERSION } from "../web/app/save-format.ts"; +import { store } from "../web/app/store.ts"; +import { KEY, load, save, cancelSave } from "../web/app/storage.ts"; +const raw = readFileSync(new URL("fixtures/save-v1.json", import.meta.url), "utf8"); +const original = JSON.parse(raw); + +test("the previous vanilla app's browser save round-trips without a migration", () => { + assert.equal(VERSION, 1); + assert.deepEqual(parseSave(raw), original); +}); +test("missing optional top-level fields are supplied without losing older settings", () => { + const older = structuredClone(original); + delete older.history; + delete older.wokeAt; + const loaded = parseSave(JSON.stringify(older)); + assert.deepEqual(loaded.history, {}); + assert.equal(loaded.wokeAt, 0); + assert.deepEqual(loaded.settings, { chatter: true }); + assert.equal(loaded.pet.name, "Mochi"); +}); +test("corrupt, incomplete and newer saves are rejected", () => { + assert.throws(() => parseSave("not json")); + assert.throws(() => parseSave('{"v":1}'), /missing/); + assert.throws( + () => parseSave(JSON.stringify({ ...original, v: 99 })), + (error) => error.newer === true, + ); +}); +test("bad saves, reset and loss of ownership block debounced writes", async () => { + const entries = new Map([[KEY, JSON.stringify({ ...original, v: 99 })]]); + const storage = { getItem: (k) => entries.get(k) ?? null, setItem: (k, v) => entries.set(k, v) }; + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + const untouched = entries.get(KEY); + store.tabOwned = true; + store.resetting = false; + store.state = 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); + save(); + store.tabOwned = false; + await new Promise((resolve) => setTimeout(resolve, 240)); + assert.equal(entries.get(KEY), untouched); + store.tabOwned = true; + store.resetting = true; + save(); + await new Promise((resolve) => setTimeout(resolve, 240)); + assert.equal(entries.get(KEY), untouched); + store.resetting = false; + save(); + await new Promise((resolve) => setTimeout(resolve, 240)); + assert.deepEqual(JSON.parse(entries.get(KEY)), original); + cancelSave(); + delete globalThis.localStorage; +}); diff --git a/train/check_onnx.py b/train/check_onnx.py index 2e10516..f0822ba 100644 --- a/train/check_onnx.py +++ b/train/check_onnx.py @@ -27,7 +27,7 @@ for f in ["onnx/model_q4.onnx"]: m = onnx.load(f"{d}/{f}", load_external_data=False) big = sorted(((np.prod(i.dims), i.name, i.data_type) for i in m.graph.initializer), reverse=True)[:3] so = ort.SessionOptions() - so.add_session_config_entry("session.disable_quant_qdq", "1") # as web/brain.js: fold to fp32 + so.add_session_config_entry("session.disable_quant_qdq", "1") # as web/brain.ts: fold to fp32 sess = ort.InferenceSession(f"{d}/{f}", so, providers=["CPUExecutionProvider"]) names = [i.name for i in sess.get_inputs()] L = sum(1 for n in names if n.endswith(".key")) diff --git a/train/quant_eval.py b/train/quant_eval.py index e1ab412..7afaf68 100644 --- a/train/quant_eval.py +++ b/train/quant_eval.py @@ -54,7 +54,7 @@ def nll(logits, ids, lab): model = GPT2LMHeadModel.from_pretrained(ckpt).eval() results = {"torch fp32": [0, 0]} so = ort.SessionOptions() -so.add_session_config_entry("session.disable_quant_qdq", "1") # as web/brain.js: fold to fp32 +so.add_session_config_entry("session.disable_quant_qdq", "1") # as web/brain.ts: fold to fp32 sessions = {"q4 (published)": ort.InferenceSession(f"{exp}/eval/model_q4_full.onnx", so, providers=["CPUExecutionProvider"])} heads = model.config.n_head for f in sessions: diff --git a/train/world.py b/train/world.py index 09ef61f..e5449ea 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/pet.js) builds exactly the same strings. Keep the two in +The browser app (web/world.ts) builds 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/pet.js buildHeader().""" + """kind: 'chat' or 'diary'. Must match web/world.ts header().""" 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.js emits. Offers ask the pet to decide: its next line must +# formats web/world.ts 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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..783df36 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noUnusedLocals": true + }, + "include": ["web"] +} diff --git a/web/app/animation.ts b/web/app/animation.ts new file mode 100644 index 0000000..e57ee54 --- /dev/null +++ b/web/app/animation.ts @@ -0,0 +1,99 @@ +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 { clamp, rand } from "./utils.ts"; + +// Animations queue, so "[emote:happy] [emote:dance]" plays both in order. +export function animate(name: string, ms = 1800, now = false) { + if (now || !view.anim) view.anim = { name, t0: performance.now(), until: performance.now() + ms }; + else if (view.queue.length < 4) view.queue.push({ name, ms }); +} +export function nextAnim(t: number) { + // A sleeping pet does not act out anything left over from before it dozed off. + if (store.state.asleep) { + view.queue = []; + if (!["sleepy", "stretch"].includes(view.anim?.name || "")) view.anim = null; + } + if (view.anim && t > view.anim.until) view.anim = null; + if (!view.anim && view.queue.length) { + const a = view.queue.shift()!; + view.anim = { name: a.name, t0: t, until: t + a.ms }; + } +} +export function float(item: string, n = 3) { + for (let i = 0; i < n; i++) + view.effects.push({ + kind: "float", + item, + x: view.x - 6 + Math.random() * 12, + y: FLOOR - 18, + t0: performance.now() + i * 220, + until: performance.now() + 2600 + i * 220, + }); +} +export function bubbles(n = 10) { + for (let i = 0; i < n; i++) + view.effects.push({ + kind: "bubble", + x: view.x - 10 + Math.random() * 20, + y: FLOOR - Math.random() * 6, + t0: performance.now() + i * 120, + until: performance.now() + 2400 + i * 120, + }); +} +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; +} diff --git a/web/app/body.ts b/web/app/body.ts new file mode 100644 index 0000000..e0740c1 --- /dev/null +++ b/web/app/body.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..9731926 --- /dev/null +++ b/web/app/brain.ts @@ -0,0 +1,293 @@ +import type { Generated, Generation, WorkerRequest, WorkerResponse } from "../types.ts"; +import { CFG } from "./config.ts"; +import { pump, scheduleIdle } from "./conversation.ts"; +import { debugTurn } from "./debug.ts"; +import { keepOffline } from "./pwa.ts"; +import { brain, emit, store, toast, ui, upgrade } from "./store.ts"; +import { errorMessage, fmtMB } from "./utils.ts"; + +let genId = 0; +const pending = new Map< + number, + { + resolve: (result: Generated) => void; + reject: (error: Error) => void; + onToken?: (text: string) => void; + } +>(); +// What transformers.js fetches for a model, and where it keeps it: Cache +// Storage, keyed by the absolute file URL. +export const BRAIN_CACHE = "transformers-cache"; +export const MODEL_FILES = [ + "tokenizer_config.json", + "tokenizer.json", + "config.json", + "generation_config.json", + "onnx/model_q4.onnx", +]; +// The ONNX Runtime build the vendored transformers.js loads (asyncify, 26.9 MB), +// fetched once alongside the first brain. +export const RUNTIME_BYTES = 27e6; +export const modelsBase = new URL(CFG.models, location.href).href; +const modelUrl = (id: string, f: string) => `${modelsBase}${id}/${f}`; +// Brains this app can talk to, newest first. Older ones speak the same prompt +// format but may lack features trained into newer ones. +export const BRAINS = [ + { id: CFG.modelId, size: CFG.size, features: CFG.features || [] }, + ...(CFG.older || []), +]; +const brainInfo = (id: string | null) => BRAINS.find((b) => b.id === id); +// Feature name -> what it lets the pet do, for the upgrade dialog. The page +// config lists which brains have which features. + +export const brainHas = (f?: string) => + !f || (brain.id ? brainInfo(brain.id)?.features || [] : BRAINS[0].features).includes(f); +export const olderBrain = () => !!brain.id && brain.id !== CFG.modelId; + +export async function cachedBrains() { + try { + if (!("caches" in self)) return []; + const keys = await (await caches.open(BRAIN_CACHE)).keys(); + return BRAINS.map((b) => b.id).filter((id) => + keys.some((r) => r.url === modelUrl(id, "onnx/model_q4.onnx")), + ); + } catch { + return []; + } +} + +// Delete model files of every brain except the ones in keep (only under this +// page's models URL; the cache holds nothing else of ours). +export async function forgetBrains(keep: string[]) { + try { + const cache = await caches.open(BRAIN_CACHE); + for (const r of await cache.keys()) { + if (!r.url.startsWith(modelsBase)) continue; + const id = r.url.slice(modelsBase.length).split("/")[0]; + if (!keep.includes(id)) await cache.delete(r); + } + } catch { + /* optional */ + } + void updateStorage(); +} + +// The upgrade to CFG.modelId downloads into the cache while the older brain +// keeps running, then swaps brains. Nothing about the pet changes. + +async function fetchBrain(id: string, onBytes: (bytes: number) => void) { + const cache = await caches.open(BRAIN_CACHE); + for (const f of MODEL_FILES) { + const url = modelUrl(id, f); + if (await cache.match(url)) continue; + const res = await fetch(url); + if (!res.ok || !res.body) throw new Error(`${f}: HTTP ${res.status}`); + const [keep, count] = res.body.tee(); + const put = cache.put(url, new Response(keep, { status: res.status, headers: res.headers })); + const reader = count.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + onBytes(value.length); + } + await put; + } +} +export async function startUpgrade() { + closeUpgrade(); + if (upgrade.state === "downloading") return; + if (!brain.worker) { + loadBrain(CFG.modelId); + return; + } // nothing running: a plain download + Object.assign(upgrade, { state: "downloading", loaded: 0, error: "" }); + try { + navigator.storage?.persist?.().catch(() => {}); + } catch { + /* optional */ + } + emit(); + let shown = 0; + try { + await fetchBrain(CFG.modelId, (n) => { + upgrade.loaded += n; + if (upgrade.loaded - shown > 1e6) { + shown = upgrade.loaded; + emit(); + } + }); + } catch (e) { + Object.assign(upgrade, { state: "error", error: errorMessage(e) }); + emit(); + return; + } + // Swap once the old brain is between turns. + while (brain.busy) await new Promise((r) => setTimeout(r, 200)); + brain.worker?.terminate(); + clearTimeout(watchdog); + Object.assign(brain, { worker: null, state: "off", busy: false, progress: null, id: null }); + upgrade.state = "swapping"; + loadBrain(CFG.modelId); +} + +export function onMobileData() { + const c = navigator.connection; + return !!c && (c.saveData || c.type === "cellular"); +} + +let upgradeOffered = false; +export function offerUpgrade() { + if (upgradeOffered || !olderBrain() || upgrade.state !== "idle") return; + upgradeOffered = true; + ui.upgradeOpen = true; + emit(); +} +export function closeUpgrade() { + ui.upgradeOpen = false; + emit(); +} +// If the runtime stalls (no progress, no error, no ready), retry once with a +// single thread, which needs no SharedArrayBuffer, then give up. +let watchdog = 0; +function armWatchdog() { + clearTimeout(watchdog); + watchdog = setTimeout(() => { + if (brain.state === "ready" || brain.state === "off" || !brain.worker) return; + console.warn("brain load stalled", brain.threads ? "on one thread" : ""); + brain.worker.terminate(); + brain.worker = null; + if (brain.threads === 1) { + brain.state = "error"; + brain.error = "the model runtime did not start"; + emit(); + return; + } + brain.threads = 1; + loadBrain(brain.id || CFG.modelId); + }, 90e3); +} + +export async function loadBrain(id = CFG.modelId) { + if (brain.worker || !store.tabOwned) return; + // Firefox may leave its persistence permission prompt unanswered. Storage + // persistence is optional and must not hold up the download (or a retry). + try { + navigator.storage?.persist?.().catch(() => {}); + } catch { + /* optional */ + } + Object.assign(brain, { state: "downloading", id, progress: null, error: "" }); + emit(); + const w = new Worker(new URL("./brain.js", import.meta.url), { type: "module" }); + brain.worker = w; + const fail = async (message: string) => { + clearTimeout(watchdog); + w.terminate(); + for (const p of pending.values()) p.reject(new Error(message)); + pending.clear(); + // An interrupted body can leave a padded, invalid ONNX in the runtime's + // cache. Retry must fetch that file again, without discarding other brains + // or a valid model after unrelated runtime failures (e.g. out of memory). + if (/error in input stream|protobuf parsing failed/i.test(message)) { + try { + await (await caches.open(BRAIN_CACHE)).delete(modelUrl(id, "onnx/model_q4.onnx")); + } catch { + /* optional */ + } + } + if (brain.worker !== w) return; + Object.assign(brain, { worker: null, busy: false, state: "error", error: message }); + emit(); + }; + w.onmessage = ({ data }: MessageEvent) => { + if (brain.worker !== w) return; + if (data.type === "progress") { + brain.progress = data; + armWatchdog(); // fires only after 90 s without progress or ready + emit(); + } else if (data.type === "loading") { + brain.state = "warming"; + armWatchdog(); + emit(); + } else if (data.type === "ready") { + clearTimeout(watchdog); + brain.state = "ready"; + if (upgrade.state === "swapping") { + upgrade.state = "idle"; + toast(`${store.state.pet?.name || "Your pet"} has its new brain!`, "sparkle"); + } + // Keep only the brain that runs (and the current one, if it is half there). + forgetBrains(olderBrain() ? [id, CFG.modelId] : [id]); + emit(); + scheduleIdle(4000); + pump(); + keepOffline(data.resources || []); + if (olderBrain()) setTimeout(offerUpgrade, 1500); + } else if (data.type === "token") pending.get(data.id)?.onToken?.(data.text); + else if (data.type === "done") { + pending.get(data.id)?.resolve(data); + pending.delete(data.id); + } else if (data.type === "error") { + if (data.id && pending.has(data.id)) { + pending.get(data.id)!.reject(new Error(data.message)); + pending.delete(data.id); + } else fail(data.message); + } + }; + w.onerror = (e) => { + if (brain.worker === w) fail(e.message || "worker failed"); + }; + w.postMessage({ + type: "load", + runtime: CFG.runtime, + models: CFG.models, + modelId: id, + dtype: "q4", + threads: brain.threads || undefined, + } satisfies WorkerRequest); + armWatchdog(); +} + +export function gen(opts: Generation, onToken?: (text: string) => void): Promise { + const id = ++genId; + return new Promise((resolve, reject) => { + pending.set(id, { + resolve: (r) => { + debugTurn(opts, r); + resolve(r); + }, + reject, + onToken, + }); + brain.worker!.postMessage({ type: "generate", id, ...opts } satisfies WorkerRequest); + }); +} + +export async function updateStorage() { + const out: string[] = []; + try { + if (navigator.storage?.estimate) + out.push( + `This site uses ${fmtMB((await navigator.storage.estimate()).usage || 0)} of browser storage.`, + ); + if (navigator.storage?.persisted) + out.push( + (await navigator.storage.persisted()) + ? "Your browser keeps it until you delete it." + : "Your browser may clear it if you don't visit for a while; installing the app or exporting a save keeps your pet safe.", + ); + } catch { + /* optional */ + } + ui.storage = out.join(" "); + emit(); +} +export function stopBrain() { + clearTimeout(watchdog); + brain.worker?.terminate(); + for (const p of pending.values()) p.reject(new Error("brain stopped")); + pending.clear(); + Object.assign(brain, { worker: null, state: "off", busy: false, progress: null, id: null }); + emit(); +} +export const getBrain = () => (olderBrain() ? startUpgrade() : loadBrain()); diff --git a/web/app/canvas.ts b/web/app/canvas.ts new file mode 100644 index 0000000..521c86d --- /dev/null +++ b/web/app/canvas.ts @@ -0,0 +1,136 @@ +import { drawScene, W as LW } from "../scene.ts"; +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 { brain, store, view } from "./store.ts"; +import { clamp } from "./utils.ts"; + +function frame(now: number) { + if (!store.tabOwned) return; // another tab has the pet now + if (!store.badEgg) tick(); + nextAnim(now); + view.effects = view.effects.filter((f) => now < f.until); + // The pet only walks where the brain sent it ([walk:left] / [walk:right]). + if ( + store.state.pet && + !store.state.asleep && + !view.anim && + !view.game && + Math.abs(view.target - view.x) >= 1 + ) { + view.walking = true; + view.facing = Math.sign(view.target - view.x); + view.x += view.facing * 0.3; + } else if (!view.game) view.walking = false; + if (view.game) stepGame(now); + const d = new Date(); + const dozing = !!store.state.pet && brain.state !== "ready"; + drawScene( + ctx, + { + lightsOff: store.state.lightsOff, + weather: weatherAt(Date.now()), + hour: d.getHours(), + shelf: store.state.shelf, + poop: store.state.poop, + egg: store.state.pet ? null : eggView(), + pet: store.state.pet && { + stage: store.state.pet!.stage, + color: store.state.pet!.color, + x: view.x, + facing: view.facing, + walking: view.walking, + anim: view.anim, + asleep: store.state.asleep || dozing, + sick: store.state.sick, + }, + effects: view.effects, + game: view.game, + want: view.game || dozing || now > view.wantUntil ? null : view.want, + dream: store.state.asleep && !dozing && now < view.dreamUntil ? view.dream : null, + offer: + view.game || !store.state.offer + ? null + : store.state.offer.item || OFFER_ICON[store.state.offer.kind], + thinking: view.thinking && !view.game, + clock: view.game + ? null + : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`, + }, + now, + ); +} + +let ctx: CanvasRenderingContext2D; +export function attachCanvas(canvas: HTMLCanvasElement) { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D is unavailable"); + ctx = context; + let request = 0; + const loop = (now: number) => { + if (!store.tabOwned) return; + frame(now); + request = requestAnimationFrame(loop); + }; + request = requestAnimationFrame(loop); + const controller = new AbortController(), + options = { signal: controller.signal }; + canvas.addEventListener( + "pointerdown", + (e) => { + const r = canvas.getBoundingClientRect(), + x = ((e.clientX - r.left) / r.width) * LW; + if (view.game) { + view.target = clamp(x, 10, LW - 10); + return; + } + if (store.state.pet && Math.abs(x - view.x) < 14) act("pat"); + }, + options, + ); + canvas.addEventListener( + "pointermove", + (e) => { + if (!view.game || !(e.buttons || e.pointerType === "mouse")) return; + const r = canvas.getBoundingClientRect(); + view.target = clamp(((e.clientX - r.left) / r.width) * LW, 10, LW - 10); + }, + options, + ); + const key = (down: boolean) => (e: KeyboardEvent) => { + if (!view.game) return; + const id = `key:${e.code || e.key.toLowerCase()}`; + if (!down) { + if (view.game.held.has(id)) { + gameControl(id, 0); + e.preventDefault(); + } + return; + } + const target = e.target instanceof Element ? e.target : null; + if (target?.closest("input, textarea, select, [contenteditable], [role=tab]")) return; + const k = e.key.toLowerCase(), + pad = (k === " " || k === "enter") && target?.closest("#pad-left, #pad-right"); + const dir = pad + ? pad.id === "pad-left" + ? -1 + : 1 + : k === "arrowleft" || k === "a" + ? -1 + : k === "arrowright" || k === "d" + ? 1 + : 0; + if (!dir) return; + e.preventDefault(); + if (!e.repeat) gameControl(id, dir); + }; + document.addEventListener("keydown", key(true), options); + document.addEventListener("keyup", key(false), options); + return () => { + controller.abort(); + cancelAnimationFrame(request); + stopGameControls(); + }; +} diff --git a/web/app/care.ts b/web/app/care.ts new file mode 100644 index 0000000..6968d6a --- /dev/null +++ b/web/app/care.ts @@ -0,0 +1,178 @@ +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 { 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"; + +// ---------- egg & hatching ---------- +// The egg incubates while the brain downloads: no brain, no pet. +export function eggView() { + if (store.badEgg) return { progress: 0, ready: false, bad: true }; + if (!store.state.egg) return { progress: 0, ready: false }; + const ready = brain.state === "ready"; + 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. +export function act(kind: Action, arg?: string) { + if (!store.tabOwned || !store.state.pet || view.game) return; + const o = store.state.owner, + p = store.state.pet!.name; + if (brain.state !== "ready") { + 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(); +} diff --git a/web/app/commands.ts b/web/app/commands.ts new file mode 100644 index 0000000..5bcb4b7 --- /dev/null +++ b/web/app/commands.ts @@ -0,0 +1,136 @@ +import { disturb } from "./animation.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 { brain, emit, store, toast, upgrade } from "./store.ts"; +import { cleanName, dateKey, download, errorMessage } from "./utils.ts"; + +export function getEgg(name: string) { + if (!store.tabOwned || store.badEgg) return; + const value = cleanName(name, 24); + if (!value) return; + store.state.owner = value; + store.state.egg ||= { laid: Date.now() }; + save(); + emit(); + void loadBrain(); +} +export function hatchPet(name: string) { + const value = cleanName(name, 16); + if (value && store.tabOwned && !store.badEgg && brain.state === "ready") hatch(value); +} +export function sendMessage(value: string) { + const text = value + .replace(/[\n\[\]{}*]/g, " ") + .trim() + .slice(0, 200); + if (!text || !store.state.pet || brain.state !== "ready" || !store.tabOwned) return false; + 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; +} +export function renameOwner(value: string) { + const name = cleanName(value, 24); + if (name) { + store.state.owner = name; + save(); + emit(); + } +} +export function setChatter(value: boolean) { + store.state.settings.chatter = value; + scheduleIdle(); + save(); + emit(); +} +export function forgetMemory(index: number) { + store.state.memories.splice(index, 1); + save(); + emit(); +} +export async function deleteBrain() { + if ( + !confirm( + `Delete the downloaded brain from this browser? ${store.state.pet?.name || "Your pet"} will doze until you download it again. The pet and diary stay.`, + ) + ) + return; + stopBrain(); + upgrade.state = "idle"; + await forgetBrains([]); + emit(); +} +export function exportSave() { + download( + JSON.stringify(store.state, null, 1), + `tokenling-${store.state.pet?.name || "egg"}-${dateKey(Date.now())}.json`, + ); +} +export async function importSave(file: File) { + try { + const next = parseSave(await file.text()); + if ( + !confirm( + `Replace ${store.badEgg ? "the unreadable save" : "your current pet"} with ${next.pet?.name || "this save"}?`, + ) + ) + return; + // In-flight replies belong to the previous pet. Reload also resets animation, + // queued offers/turns and ensures Web Lock ownership before reading the import. + store.resetting = true; + cancelSave(); + stopBrain(); + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch (error) { + store.resetting = false; + throw error; + } + location.reload(); + } catch (error) { + toast(`Could not import: ${errorMessage(error)}`); + } +} +export function resetSave() { + if ( + !confirm( + `Say goodbye to ${store.state.pet?.name || "your egg"} and start over? This deletes the pet, your name, chat, memories, gifts and diary from this browser.`, + ) + ) + return; + store.resetting = true; + cancelSave(); + stopBrain(); + try { + localStorage.removeItem(KEY); + } catch { + /* storage unavailable */ + } + location.reload(); +} +export function exportBadEgg() { + if (store.badEgg) download(store.badEgg.raw, `tokenling-bad-egg-${dateKey(Date.now())}.json`); +} +export function replaceBadEgg() { + if ( + !store.badEgg || + !confirm( + "Start with a new egg? The unreadable save is moved aside in this browser (tokenling.bad), replacing any older one there. Download it first if you might want it back.", + ) + ) + return; + try { + localStorage.setItem(BAD_KEY, store.badEgg.raw); + localStorage.removeItem(KEY); + } catch { + toast("Couldn't move the old save aside, so it's still there. Download it, then try again."); + return; + } + location.reload(); +} diff --git a/web/app/config.ts b/web/app/config.ts new file mode 100644 index 0000000..2772fda --- /dev/null +++ b/web/app/config.ts @@ -0,0 +1,51 @@ +import type { Action, Config, Stage } from "../types.ts"; +import { DAY, HOUR } from "./utils.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); +export const DEBUG = + new URLSearchParams(location.search).get("debug") === "1" || location.hash.slice(1) === "debug"; +export { DAY, HOUR, MIN } from "./utils.ts"; +export const WANT_ICON: Record = { + snack: "cookie", + play: "ball", + bath: "bubbles", + sleep: "moon", + pats: "heart", + medicine: "pill", +}; +export const OFFER_ICON: Record = { + bath: "bubbles", + medicine: "pill", + play: "ball", +}; +export const NAME_IDEAS = [ + "Mochi", + "Pip", + "Bean", + "Tofu", + "Nori", + "Puddle", + "Sprout", + "Pebble", + "Momo", + "Kiwi", + "Dumpling", + "Boba", + "Miso", + "Clover", + "Noodle", + "Gumdrop", + "Yuzu", + "Biscuit", + "Fig", + "Bun", +]; +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 new file mode 100644 index 0000000..6c9ac54 --- /dev/null +++ b/web/app/conversation.ts @@ -0,0 +1,219 @@ +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"; + +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; +export function wantTurn(kind: TurnKind = "react") { + if (kind === "react" || !turnWanted) turnWanted = kind; + setTimeout(pump, 0); +} +export function pump() { + if ( + !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); + }); +} +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 ( + 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"); +} + +let staleRuns = 0; +export async function petTurn(kind: TurnKind = "react") { + if (!store.tabOwned || !store.state.pet || brain.state !== "ready" || brain.busy) return false; + brain.busy = true; + emit(); + view.thinking = kind === "react"; + const seen = lineCount, + offer = store.state.offer, + wasAsleep = store.state.asleep; + let bubble = kind === "react" ? liveBubble() : null; + const live = (t: string) => { + const shown = NOT_CUTE.test(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 = ""; + } + bubble?.remove(); + // 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) { + bubble?.remove(); + console.warn(e); + } finally { + 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) { + 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 new file mode 100644 index 0000000..87d2a1e --- /dev/null +++ b/web/app/debug.ts @@ -0,0 +1,44 @@ +import type { Generated, Generation } from "../types.ts"; +import { disturb, disturbances, resetDisturbances, setWakeRoll } from "./animation.ts"; +import { act } from "./care.ts"; +import { DEBUG } from "./config.ts"; +import { applyTags, petTurn, wantTurn } from "./conversation.ts"; +import { writeDiary } from "./diary.ts"; +import { simulate, stageFor } from "./simulation.ts"; +import { save } from "./storage.ts"; +import { brain, emit, store, toast, ui, upgrade, view } from "./store.ts"; +export function debugTurn(opts: Generation, result: Generated) { + if (!DEBUG) return; + ui.debugLog.push({ ...result, at: new Date(), mode: opts.mode }); + if (ui.debugLog.length > 20) ui.debugLog.shift(); + emit(); +} +export function setAge(ms: number) { + const pet = store.state.pet; + if (!pet) return; + pet.hatched = Date.now() - ms; + pet.stage = stageFor(ms); + save(); + emit(); +} +export const copyText = (text: string) => + navigator.clipboard.writeText(text).then( + () => toast("Copied."), + () => toast("Couldn't copy."), + ); +const debug = { setWakeRoll, disturb, disturbances, resetDisturbances, applyTags, simulate }; +export function testControls() { + return { + state: () => store.state, + brain, + view, + act, + petTurn, + writeDiary, + wantTurn, + renderAll: emit, + upgrade, + ...(DEBUG ? { debug } : {}), + }; +} +export type TestControls = ReturnType; diff --git a/web/app/diary.ts b/web/app/diary.ts new file mode 100644 index 0000000..c5e948a --- /dev/null +++ b/web/app/diary.ts @@ -0,0 +1,119 @@ +import { SPRITES } from "../sprites.ts"; +import * as Wd from "../world.ts"; +import { animate, resetDisturbances } from "./animation.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"; + +// ---------- 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() { + 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) { + if (brain.state !== "ready" || brain.busy || !store.state.pet) return false; + 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; + 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 (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, + }); + 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); + } finally { + brain.busy = false; + ui.diaryWriting = false; + emit(); + pump(); + } + return true; +} diff --git a/web/app/game.ts b/web/app/game.ts new file mode 100644 index 0000000..6a503da --- /dev/null +++ b/web/app/game.ts @@ -0,0 +1,91 @@ +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 { clamp } from "./utils.ts"; + +// ---------- catch minigame (after the pet said yes) ---------- +export function startGame() { + if (!store.tabOwned || !store.state.pet || store.state.asleep || view.game) return; + view.game = { + stars: [], + score: 0, + timeLeft: 20, + last: performance.now(), + spawn: 0, + dir: 0, + held: new Map(), + }; + view.target = view.x; + emit(); +} +export function gameControl(id: string, dir: number) { + const g = view.game; + if (!g) return; + g.held.delete(id); + if (dir) g.held.set(id, dir); + // The newest held input wins; releasing another finger/key cannot stop it. + g.dir = [...g.held.values()].at(-1) || 0; + view.target = view.x; +} +export function stopGameControls() { + if (!view.game) return; + view.game.held.clear(); + view.game.dir = 0; + view.target = view.x; +} +export function stepGame(now: number) { + const g = view.game!; + const dt = Math.min(0.05, (now - g.last) / 1000); + g.last = now; + g.timeLeft -= dt; + g.spawn -= dt; + if (g.spawn <= 0) { + g.stars.push({ + x: 8 + Math.random() * (LW - 16), + y: -4, + v: 18 + Math.random() * 14 + (20 - g.timeLeft), + bad: Math.random() < 0.18, + }); + g.spawn = 0.55 + Math.random() * 0.5; + } + if (g.dir) view.target = clamp(view.x + g.dir * 70 * dt, 10, LW - 10); + const dx = view.target - view.x; + if (dx) { + view.facing = Math.sign(dx); + view.x += Math.sign(dx) * Math.min(Math.abs(dx), 70 * dt); + view.walking = true; + } else view.walking = false; + for (const s of g.stars) { + s.y += s.v * dt; + if (!s.done && s.y > FLOOR - 16 && s.y < FLOOR - 4 && Math.abs(s.x - view.x) < 10) { + s.done = true; + if (s.bad) { + g.score = Math.max(0, g.score - 1); + animate("wow", 400, true); + } else { + g.score++; + animate("happy", 400, true); + } + } + } + g.stars = g.stars.filter((s) => !s.done && s.y < FLOOR + 4); + if (g.timeLeft <= 0) endGame(); +} +export function endGame() { + const n = view.game!.score; + view.game = null; + view.target = view.x; + emit(); + 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(); +} diff --git a/web/app/lifecycle.ts b/web/app/lifecycle.ts new file mode 100644 index 0000000..d3e52ae --- /dev/null +++ b/web/app/lifecycle.ts @@ -0,0 +1,93 @@ +import { cachedBrains, loadBrain, stopBrain, updateStorage } from "./brain.ts"; +import { CFG } from "./config.ts"; +import { stopGameControls } from "./game.ts"; +import { planAlerts } from "./pwa.ts"; +import { catchUp } from "./simulation.ts"; +import { cancelSave, fresh, KEY, load } from "./storage.ts"; +import { brain, emit, store } from "./store.ts"; + +let releaseTab: (() => void) | null = null; + +function requestTab(steal: boolean): Promise { + if (!navigator.locks) return Promise.resolve(true); + return new Promise((resolve) => { + navigator.locks + .request(KEY, steal ? { steal: true } : { ifAvailable: true }, (lock) => { + resolve(!!lock); + return lock + ? new Promise((release) => { + releaseTab = release; + }) + : undefined; + }) + .catch(() => { + resolve(false); + lostTab(); + }); + }); +} +export async function claimTab(steal: boolean): Promise { + // A reload can request its new lock before the old document's release reaches + // the lock manager. Retry briefly without stealing from another live tab. + for (let attempt = 0; attempt < 3; attempt++) { + if (await requestTab(steal && attempt === 0)) return true; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; +} +export function lostTab() { + store.tabOwned = false; + cancelSave(); + stopBrain(); + stopGameControls(); + emit(); +} +export function takeOver() { + try { + sessionStorage.setItem("tokenling.steal", "1"); + } catch { + /* optional */ + } + location.reload(); +} +export async function boot() { + let steal = false; + try { + steal = sessionStorage.getItem("tokenling.steal") === "1"; + sessionStorage.removeItem("tokenling.steal"); + } catch { + /* optional */ + } + store.tabOwned = await claimTab(steal); + store.state = store.tabOwned ? load() : fresh(); + if (!store.tabOwned) return; + addEventListener("pagehide", () => { + void planAlerts(); + lostTab(); + releaseTab?.(); + releaseTab = null; + }); + addEventListener("pageshow", (event) => { + if (event.persisted) location.reload(); + }); + if (store.badEgg) return; + catchUp(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) stopGameControls(); + if (!store.tabOwned) return; + if (!document.hidden) { + catchUp(); + emit(); + } + void planAlerts(); + }); + addEventListener("blur", stopGameControls); + void planAlerts(); + void updateStorage(); +} +export async function resumeBrain() { + if (!store.tabOwned || store.badEgg) return; + const have = await cachedBrains(); + if (store.state.egg && store.state.owner) void loadBrain(CFG.modelId); + else if (store.state.pet && have.length && !brain.worker) void loadBrain(have[0]); +} diff --git a/web/app/pwa.ts b/web/app/pwa.ts new file mode 100644 index 0000000..5596459 --- /dev/null +++ b/web/app/pwa.ts @@ -0,0 +1,199 @@ +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 { brain, emit, store, ui } from "./store.ts"; + +// sw.js (beside the page) keeps the page, the app and the runtime for offline +// use; the brain itself already lives in Cache Storage. The page tells it +// what to keep, so it never needs to know the site's layout. +export const swReady = + CFG.sw && "serviceWorker" in navigator + ? navigator.serviceWorker + .register(new URL(CFG.sw, location.href)) + .then(() => navigator.serviceWorker.ready) + .catch((e) => { + console.warn("service worker", e); + return null; + }) + : Promise.resolve(null); +const APP_FILES = ["main.js", "brain.js"].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); + return [...new Set([page, ...APP_FILES, ...seen, ...more])].filter( + (u) => + u.startsWith(location.origin) && + !u.startsWith(modelsBase) && + !/\/sw\.js$/.test(u.split("?")[0]), + ); +} +// Called once the brain is up: by then everything the app needs has loaded, +// so the worker can drop anything older (a previous app version or runtime). +export async function keepOffline(workerResources: string[]) { + const reg = await swReady; + reg?.active?.postMessage({ type: "keep", urls: pageResources(workerResources), prune: true }); +} +swReady.then((reg) => + reg?.active?.postMessage({ type: "keep", urls: pageResources(), prune: false }), +); + +export const standalone = () => + matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; +export const isIOS = () => + /iPhone|iPad|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); +export let installEvent: InstallPrompt | null = null; +addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + installEvent = e as InstallPrompt; + emit(); +}); +addEventListener("appinstalled", () => { + installEvent = null; + emit(); +}); +export async function installApp() { + if (!installEvent) return; + installEvent.prompt(); + try { + await installEvent.userChoice; + } catch { + /* dismissed */ + } + installEvent = null; + emit(); +} +export const ALERT_TAG = "tokenling-alerts"; +async function alertsUrl() { + const reg = await swReady; + return reg && new URL("tokenling-alerts.json", reg.scope).href; +} +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; +} +// 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). +export async function planAlerts() { + if (!store.tabOwned) return; + const url = await alertsUrl(); + if (!url) return; + try { + const c = await caches.open(ALERT_TAG); + if (!store.state.settings.alerts || !store.state.pet) { + await c.delete(url); + return; + } + const icon = new URL(CFG.icon || "", location.href).href; + await c.put( + url, + new Response(JSON.stringify({ icon, items: alertPlan() }), { + headers: { "content-type": "application/json" }, + }), + ); + } catch { + /* optional */ + } +} +// While the tab is hidden but still running, ask the worker once a minute. +setInterval(async () => { + if (!document.hidden || !store.state?.settings.alerts) return; + (await swReady)?.active?.postMessage({ type: "alerts" }); +}, 60e3); +export async function setAlerts(on: boolean) { + store.state.settings.alertsAsked = true; + if (!on) { + store.state.settings.alerts = false; + save(); + planAlerts(); + emit(); + return; + } + const reg = await swReady; + if (!reg || !("Notification" in self)) { + ui.alertsNote = "This browser can't show alerts from here."; + emit(); + return; + } + const perm = + Notification.permission === "granted" ? "granted" : await Notification.requestPermission(); + 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(); + return; + } + store.state.settings.alerts = true; + save(); + try { + await reg.periodicSync?.register(ALERT_TAG, { minInterval: HOUR }); + } catch { + /* not offered: alerts work while the page runs */ + } + planAlerts(); + emit(); +} + +export function getTip() { + const s = store.state; + if (!s?.pet || brain.state !== "ready" || olderBrain()) return null; + if (!standalone() && !s.settings.installAsked && (installEvent || isIOS())) { + return { + 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(); + void installApp(); + emit(); + }, + no: () => { + s.settings.installAsked = true; + save(); + emit(); + }, + }; + } + if (standalone() && !s.settings.alertsAsked && "Notification" in self) + return { + 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(); + }, + }; + return null; +} diff --git a/web/app/save-format.ts b/web/app/save-format.ts new file mode 100644 index 0000000..0dd8043 --- /dev/null +++ b/web/app/save-format.ts @@ -0,0 +1,60 @@ +import type { PetState } from "../types.ts"; +import { dateKey, HOUR } from "./utils.ts"; + +export function fresh(): PetState { + return { + v: 1, + 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 }, + }; +} +// Save versions. MIGRATIONS[n] turns a version n+1 save into version n+2; +// bump VERSION and add one whenever a stored shape changes. A save that cannot +// be read (corrupt, from a newer version, or a failed migration) is never +// overwritten: it becomes a "bad egg" that the owner can download or replace. +export const VERSION = 1; +export const MIGRATIONS: ((s: PetState) => PetState)[] = []; +export 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"); + return { ...fresh(), ...s }; +} diff --git a/web/app/simulation.ts b/web/app/simulation.ts new file mode 100644 index 0000000..0285f8c --- /dev/null +++ b/web/app/simulation.ts @@ -0,0 +1,192 @@ +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"; + +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]; +} +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); +} + +export function catchUp() { + 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); + 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(); + } +} + +let lastSecond = Date.now(); +export function tick() { + 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(); +} diff --git a/web/app/storage.ts b/web/app/storage.ts new file mode 100644 index 0000000..f14d871 --- /dev/null +++ b/web/app/storage.ts @@ -0,0 +1,46 @@ +import type { PetState } from "../types.ts"; +import { fresh, parseSave } from "./save-format.ts"; +import { store } from "./store.ts"; +import { errorMessage } from "./utils.ts"; +export { fresh, parseSave } from "./save-format.ts"; + +export function load(): PetState { + let raw = null; + try { + raw = localStorage.getItem(KEY); + } catch { + return fresh(); + } // storage unavailable + if (raw == null) return fresh(); + try { + return parseSave(raw); + } catch (e) { + store.badEgg = { + raw, + why: e instanceof SyntaxError ? "it is damaged" : errorMessage(e), + newer: e instanceof Error && "newer" in e && !!e.newer, + }; + return fresh(); + } +} +let saveTimer = 0; +export function save() { + // Never write while a hard reset reloads the page, over a save this page + // could not read, or from a tab that no longer holds the pet. + if (store.resetting || store.badEgg || !store.tabOwned) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + if (store.resetting || store.badEgg || !store.tabOwned) return; + try { + localStorage.setItem(KEY, JSON.stringify(store.state)); + } catch { + /* quota or private mode */ + } + }, 200); +} + +export const KEY = "tokenling.v1"; +export const BAD_KEY = "tokenling.bad"; +export function cancelSave() { + clearTimeout(saveTimer); +} diff --git a/web/app/store.ts b/web/app/store.ts new file mode 100644 index 0000000..a659be1 --- /dev/null +++ b/web/app/store.ts @@ -0,0 +1,99 @@ +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. +export const store: { + state: PetState; + badEgg: { raw: string; why: string; newer: boolean } | null; + tabOwned: boolean; + resetting: boolean; +} = { + state: fresh(), + badEgg: null, + tabOwned: false, + resetting: false, +}; +export const brain: BrainState = { + worker: null, + state: "off", + busy: false, + tps: 0, + prefillMs: 0, + progress: null, + threads: 0, + id: null, + error: "", +}; +export const upgrade: { + state: "idle" | "downloading" | "swapping" | "error"; + loaded: number; + error: string; +} = { state: "idle", loaded: 0, error: "" }; +export const view: View = { + x: 48, + facing: 1, + walking: false, + target: 48, + anim: null, + queue: [], + effects: [], + want: null, + wantUntil: 0, + dream: null, + dreamUntil: 0, + game: null, + thinking: false, +}; +export const ui = { + live: null as { text: string } | null, + diaryWriting: false, + diaryDraft: "dear diary,", + upgradeOpen: false, + storage: "", + alertsNote: "", + toasts: [] as { id: number; text: string; icon?: string }[], + debugLog: [] as (Generated & { at: Date; mode: Generation["mode"] })[], +}; +let revision = 0; +const listeners = new Set<() => void>(); +export const snapshot = () => revision; +export function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +export function emit() { + revision++; + for (const listener of listeners) listener(); +} +let toastId = 0; +export function toast(text: string, icon?: string) { + const id = ++toastId; + ui.toasts.push({ id, text, icon }); + emit(); + setTimeout(() => { + ui.toasts = ui.toasts.filter((t) => t.id !== id); + emit(); + }, 4200); +} +export function liveBubble() { + const bubble = { text: "" }; + ui.live = bubble; + emit(); + return { + update(text: string) { + if (text && ui.live === bubble) { + bubble.text = text; + emit(); + } + }, + remove() { + if (ui.live === bubble) { + ui.live = null; + emit(); + } + }, + }; +} diff --git a/web/app/text.ts b/web/app/text.ts new file mode 100644 index 0000000..aeb0629 --- /dev/null +++ b/web/app/text.ts @@ -0,0 +1,53 @@ +import * as Wd from "../world.ts"; +import { WANT_ICON } from "./config.ts"; + +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) : ""; + 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]); + else if (name === "yesterday") chips.push(["read yesterday's diary", r]); + else if (name === "remember") chips.push(["saved a memory", arg]); + 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 === "sleep") chips.push(["fell asleep", ""]); + else if (name === "yes") chips.push(["said yes", ""]); + else if (name === "no") chips.push(["said no", ""]); + else if (name === "emote" || name === "walk") + chips.push([null, name === "walk" ? `walk-${arg}` : arg]); + } + return chips; +} +export const ACT_WORD: Record = { + happy: "bounces", + love: "is in love", + sad: "sniffles", + sleepy: "yawns", + wave: "waves", + dance: "dances", + spin: "spins", + blush: "blushes", + grumpy: "grumbles", + wow: "gasps", + giggle: "giggles", + cry: "cries", + "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 new file mode 100644 index 0000000..f76ced6 --- /dev/null +++ b/web/app/utils.ts @@ -0,0 +1,24 @@ +export const HOUR = 3600e3, + MIN = 60e3, + DAY = 24 * HOUR; +export const clamp = (v: number, a = 0, b = 100) => Math.max(a, Math.min(b, v)); +export const pick = (xs: readonly T[]): T => xs[Math.floor(Math.random() * xs.length)]; +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)); +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" })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} diff --git a/web/brain.js b/web/brain.ts similarity index 58% rename from web/brain.js rename to web/brain.ts index c872379..39a51c0 100644 --- a/web/brain.js +++ b/web/brain.ts @@ -1,13 +1,27 @@ +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) // with transformers.js on WebAssembly. Weights are fetched once from this site // and kept in the browser's Cache Storage; nothing is sent anywhere. -let T, tokenizer, model, busy = false; +let T: Transformers, + tokenizer: Tokenizer, + model: Model, + busy = false; const CTX = 1024; -const post = (type, data = {}) => self.postMessage({ type, ...data }); +const post = ( + type: K, + data: Omit, "type">, +) => self.postMessage({ type, ...data }); -async function load({ runtime, models, modelId, dtype, threads }) { +async function load({ + runtime, + models, + modelId, + dtype, + threads, +}: Extract) { T = await import(`${runtime}/transformers.min.js`); if (threads) T.env.backends.onnx.wasm.numThreads = threads; T.env.allowRemoteModels = false; @@ -16,12 +30,16 @@ async function load({ runtime, models, modelId, dtype, threads }) { T.env.useBrowserCache = true; T.env.backends.onnx.wasm.wasmPaths = `${runtime}/`; T.env.backends.onnx.logLevel = "error"; - const files = new Map(); - const progress_callback = (p) => { + const files = new Map(); + const progress_callback = (p: Progress) => { if (p.status === "progress" && p.total) { files.set(p.file, [p.loaded, p.total]); - let loaded = 0, total = 0; - for (const [l, t] of files.values()) { loaded += l; total += t; } + let loaded = 0, + total = 0; + for (const [l, t] of files.values()) { + loaded += l; + total += t; + } post("progress", { loaded, total, file: p.file }); } else if (p.status === "done") { post("file", { file: p.file }); @@ -33,8 +51,12 @@ async function load({ runtime, models, modelId, dtype, threads }) { // fusing them into MatMulNBits, which is about 5x slower in WebAssembly // (train/quantize.py); without it the model does not load in a usable way. // disable_prepacking skips a second packed copy of every weight (-160 MB). - model = await T.AutoModelForCausalLM.from_pretrained(modelId, { device: "wasm", dtype, progress_callback, - session_options: { extra: { session: { disable_quant_qdq: "1", disable_prepacking: "1" } } } }); + model = await T.AutoModelForCausalLM.from_pretrained(modelId, { + device: "wasm", + dtype, + progress_callback, + session_options: { extra: { session: { disable_quant_qdq: "1", disable_prepacking: "1" } } }, + }); post("loading", { stage: "warming up" }); await model.generate({ ...tokenizer("hi"), max_new_tokens: 1, do_sample: false }); } @@ -44,17 +66,28 @@ async function load({ runtime, models, modelId, dtype, threads }) { // GPT-2 has a single "][" token, so a lookup can close mid-token. const LOOKUP_END = /\[(clock|look|yesterday|recall:[^\]\n]{1,80})\]\[?$/; -function makeStopper(promptLen, mode, state) { +function makeStopper( + promptLen: number, + mode: Generation["mode"], + state: { text: string; reason: string }, +) { class Stop extends T.StoppingCriteria { - _call(input_ids) { + _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]; } + 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]; + state.reason = "end"; + return [true]; } return [false]; } @@ -65,9 +98,9 @@ function makeStopper(promptLen, mode, state) { // 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, lines, prefix, maxNew, ctx = CTX) { +function fit(header: string, lines: string[], prefix: string, maxNew: number, ctx = CTX) { const budget = Math.min(ctx, CTX) - maxNew - 4; - const count = (s) => tokenizer.encode(s).length; + const count = (s: string) => tokenizer.encode(s).length; const hTok = count(header) + count(prefix); const kept = []; let used = hTok; @@ -80,7 +113,16 @@ function fit(header, lines, prefix, maxNew, ctx = CTX) { return header + kept.map((l) => l + "\n").join("") + prefix; } -async function generate({ id, header, lines, prefix, mode, maxNew, temperature, ctx }) { +async function generate({ + id, + header, + lines, + prefix, + mode, + maxNew, + temperature, + ctx, +}: Extract) { const prompt = fit(header, lines, prefix, maxNew, ctx); const inputs = tokenizer(prompt); const promptLen = inputs.input_ids.dims[1]; @@ -88,7 +130,10 @@ async function generate({ id, header, lines, prefix, mode, maxNew, temperature, let first = 0; const streamer = new T.TextStreamer(tokenizer, { skip_prompt: true, - callback_function: () => { first ||= performance.now(); post("token", { id, text: state.text }); }, + callback_function: () => { + first ||= performance.now(); + post("token", { id, text: state.text }); + }, }); const t0 = performance.now(); await model.generate({ @@ -104,26 +149,45 @@ async function generate({ id, header, lines, prefix, mode, maxNew, temperature, 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]*$/, ""); + if (mode !== "line") + text = text.replace(/\n##[\s\S]*$/, "").replace(/<\|endoftext\|>[\s\S]*$/, ""); // prefillMs: prompt processing up to the first new token; tps: decoding speed after it. - const end = performance.now(), n = tokenizer.encode(text).length; + const end = performance.now(), + n = tokenizer.encode(text).length; const prefillMs = (first || end) - t0; - post("done", { id, text, prompt, reason: state.reason, promptTokens: promptLen, prefillMs, - tps: n > 1 && first ? (n - 1) / ((end - first) / 1000) : 0 }); + post("done", { + id, + text, + prompt, + reason: state.reason, + promptTokens: promptLen, + prefillMs, + tps: n > 1 && first ? (n - 1) / ((end - first) / 1000) : 0, + }); } -self.onmessage = async ({ data }) => { +self.onmessage = async ({ data }: MessageEvent) => { try { if (data.type === "load") { await load(data); // The runtime files this worker fetched, so the page can keep them for offline use. - post("ready", { dtype: data.dtype, resources: performance.getEntriesByType("resource").map((e) => e.name) }); + post("ready", { + dtype: data.dtype, + resources: performance.getEntriesByType("resource").map((e) => e.name), + }); } else if (data.type === "generate") { if (busy) return post("error", { id: data.id, message: "busy" }); busy = true; - try { await generate(data); } finally { busy = false; } + try { + await generate(data); + } finally { + busy = false; + } } } catch (err) { - post("error", { id: data.id, message: String(err?.message || err) }); + post("error", { + id: data.type === "generate" ? data.id : undefined, + message: err instanceof Error ? err.message : String(err), + }); } }; diff --git a/web/components/App.tsx b/web/components/App.tsx new file mode 100644 index 0000000..438054e --- /dev/null +++ b/web/components/App.tsx @@ -0,0 +1,108 @@ +import { useRef, useState } from "preact/hooks"; +import { DEBUG } from "../app/config.ts"; +import { takeOver } from "../app/lifecycle.ts"; +import { store, ui } from "../app/store.ts"; +import { BrainNotice, UpgradeDialog } from "./Brain.tsx"; +import { Chat } from "./Chat.tsx"; +import { Debug } from "./Debug.tsx"; +import { Device } from "./Device.tsx"; +import { Diary } from "./Diary.tsx"; +import { Settings } from "./Settings.tsx"; +import { Icon, useApp } from "./shared.tsx"; +import { Shelf } from "./Shelf.tsx"; +const tabs = [ + ["chat", "chat"], + ["diary", "diary"], + ["shelf", "shelf"], + ["care", "settings"], + ...(DEBUG ? [["debug", "debug"]] : []), +]; +export function App() { + useApp(); + const [active, setActive] = useState("chat"); + const buttons = useRef(null); + if (!store.tabOwned) + return ( + + ); + return ( + <> +
    + + +
    +
    + {tabs.map(([id, label], i) => ( + + ))} +
    + {tabs.map(([id]) => ( + + ))} +
    +
    +
    + {ui.toasts.map((t) => ( +
    + {t.icon && } + {t.text} +
    + ))} +
    + + + ); +} diff --git a/web/components/Brain.tsx b/web/components/Brain.tsx new file mode 100644 index 0000000..4a80695 --- /dev/null +++ b/web/components/Brain.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef } from "preact/hooks"; +import { + brainHas, + BRAINS, + closeUpgrade, + getBrain, + olderBrain, + onMobileData, + startUpgrade, +} from "../app/brain.ts"; +import { CFG, FEATURE_TEXT } from "../app/config.ts"; +import { brain, store, ui, upgrade } from "../app/store.ts"; +import { fmtMB } from "../app/utils.ts"; +export function BrainNotice() { + const name = store.state.pet?.name; + const up = upgrade.state, + dozing = brain.state === "off" || brain.state === "error"; + const hidden = !name || !(dozing || olderBrain() || up === "swapping"); + const text = dozing + ? `${name} is dozing: its brain isn't in this browser yet. It downloads once and stays here.${onMobileData() ? " (You seem to be on mobile data.)" : ""}` + : up === "downloading" + ? `Downloading ${name}'s new brain… ${fmtMB(upgrade.loaded)} of ${fmtMB(CFG.size)}` + : up === "swapping" + ? `${name} is waking up with its new brain…` + : up === "error" + ? `The new brain didn't download (${upgrade.error}). ${name} still has its old one.` + : `A new brain is ready for ${name}. ${CFG.news || ""}`.trim(); + return ( + + ); +} +export function UpgradeDialog() { + const dialog = useRef(null), + name = store.state.pet?.name || "your pet"; + useEffect(() => { + const d = dialog.current; + if (ui.upgradeOpen && !d?.open) d?.showModal(); + else if (!ui.upgradeOpen && d?.open) d.close(); + }, [ui.upgradeOpen]); + const gains = BRAINS[0].features.filter((f) => !brainHas(f)).map((f) => FEATURE_TEXT[f] || f); + return ( + +

    A new brain for {name}

    +
    +

    {CFG.news || `${CFG.modelId} is ready.`}

    + {!!gains.length &&

    It can also: {gains.join("; ")}.

    } +

    + Download: about {fmtMB(CFG.size)}. {name} keeps its memories, chat and diary; only the + brain changes, and {name} stays awake while it downloads. +

    + {onMobileData() && ( +

    + You seem to be on mobile data. You can keep the current brain and upgrade later on + Wi-Fi. +

    + )} +
    +
    + + +
    +
    + ); +} +export function brainStatus() { + const id = brain.id || CFG.modelId, + up = upgrade.state; + return { + off: "Not downloaded. Without it your pet just dozes.", + downloading: brain.progress + ? `Downloading ${id}… ${fmtMB(brain.progress.loaded)} of ${fmtMB(brain.progress.total)}` + : "Starting…", + warming: "Waking up…", + ready: + `${id}, awake and running on this device${brain.threads === 1 ? " (one thread)" : ""}${brain.tps ? ` · ${brain.tps.toFixed(1)} tokens/s` : ""}.` + + (olderBrain() + ? ` A newer brain, ${CFG.modelId}, is available${up === "downloading" ? `: downloading, ${fmtMB(upgrade.loaded)} of ${fmtMB(CFG.size)}` : up === "error" ? ` (the download failed: ${upgrade.error})` : ""}.` + : ""), + error: `Could not load: ${brain.error}`, + }[brain.state]; +} diff --git a/web/components/Chat.tsx b/web/components/Chat.tsx new file mode 100644 index 0000000..980d535 --- /dev/null +++ b/web/components/Chat.tsx @@ -0,0 +1,128 @@ +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 { Icon } from "./shared.tsx"; +export function Chat({ active }: { active: boolean }) { + const s = store.state, + ready = brain.state === "ready"; + const [message, setMessage] = useState(""); + const list = useRef(null); + const tip = getTip(); + useLayoutEffect(() => { + if (active && list.current) list.current.scrollTop = list.current.scrollHeight; + }, [active, s.chat.length, s.chat.at(-1), ui.live, ui.live?.text]); + return ( + <> + +
      + {s.chat.slice(-80).map((line, i) => ( + + ))} + {ui.live && ( +
    1. + {s.pet?.name} +

      + {ui.live.text || ( + <> + + + + + )} +

      +
    2. + )} +
    +
    { + e.preventDefault(); + if (sendMessage(message)) setMessage(""); + }} + > + + setMessage(e.currentTarget.value)} + /> + +
    + + ); +} +function ChatLine({ line }: { line: string }) { + const p = store.state.pet?.name, + o = store.state.owner; + if (line.startsWith("* ")) return
  1. {line.slice(2, -2)}
  2. ; + if (p && line.startsWith(p + ": ")) { + const body = line.slice(p.length + 2), + chips = chipsFor(body); + const acts = chips + .filter((c) => !c[0]) + .map((c) => ACT_WORD[c[1]]) + .filter(Boolean); + const tools = chips + .filter((c) => c[0]) + .map(([what, detail, icon], i) => ( + + {icon && } + {what} + {detail && {detail}} + + )); + if (isSilent(body)) + return ( +
  3. + {`${p} ${acts.join(", ") || "potters about"}`} + {tools} +
  4. + ); + return ( +
  5. + {p} +

    + {cleanText(body)} + {acts.length > 0 && {` *${acts.join(", ")}*`}} +

    + {tools} +
  6. + ); + } + return o && line.startsWith(o + ": ") ? ( +
  7. +

    {line.slice(o.length + 2)}

    +
  8. + ) : ( +
  9. {line}
  10. + ); +} diff --git a/web/components/Debug.tsx b/web/components/Debug.tsx new file mode 100644 index 0000000..04f4aad --- /dev/null +++ b/web/components/Debug.tsx @@ -0,0 +1,161 @@ +import { useState } from "preact/hooks"; +import { disturbanceCount } from "../app/animation.ts"; +import { offerThing } from "../app/care.ts"; +import { DAY, MIN, STAGE_AGE } from "../app/config.ts"; +import { copyText, setAge } from "../app/debug.ts"; +import { writeDiary } from "../app/diary.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 type { Disturbance, Stat } from "../types.ts"; +import { header, wakeChance } from "../world.ts"; +export function Debug() { + const s = store.state, + pet = s.pet; + const [days, setDays] = useState(() => (age() / DAY).toFixed(2)); + const n = disturbanceCount() + 1; + return ( + <> +

    Pet

    + {pet ? ( + <> +

    {`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() + .reverse() + .map(([stage, ms]) => ( + + ))} +
    + +
    + setDays(e.currentTarget.value)} + /> + +
    +
    + {(Object.keys(s.stats) as Stat[]).map((k) => ( +
    + + { + s.stats[k] = +e.currentTarget.value; + save(); + emit(); + }} + /> + {Math.round(s.stats[k])} +
    + ))} +
    + +

    + {`${s.asleep ? "Asleep" : "Awake"} · next disturbance #${n}: `} + {(["talk", "touch", "light"] as Disturbance[]) + .map((k) => `${k} ${Math.round(wakeChance(s.stats.energy, k, n) * 100)}%`) + .join(" · ")} +

    +

    + Write today's page using the log so far, even if it is short. An early page skips + bedtime writing; rewrite it later to include new events. +

    + + + ) : ( +

    No pet yet.

    + )} +

    Context

    +

    + The last prompts sent to the brain, newest first; what it wrote is highlighted. Only kept + until the page reloads. +

    +
    + + +
    + {ui.debugLog + .slice() + .reverse() + .map((d) => ( +
    + {`${d.at.toLocaleTimeString()} · ${d.mode} · ${d.promptTokens} prompt tokens · stopped: ${d.reason}`} +
    +              {d.prompt}
    +              {d.text}
    +            
    + +
    + ))} + + ); +} diff --git a/web/components/Device.tsx b/web/components/Device.tsx new file mode 100644 index 0000000..13bb418 --- /dev/null +++ b/web/components/Device.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { brainHas } from "../app/brain.ts"; +import { attachCanvas } from "../app/canvas.ts"; +import { act } from "../app/care.ts"; +import { ACT_NEEDS, CFG } from "../app/config.ts"; +import { gameControl } from "../app/game.ts"; +import { dayNum } from "../app/simulation.ts"; +import { brain, store, view } from "../app/store.ts"; +import type { Action, Stat } from "../types.ts"; +import { FOODS, levelWords } from "../world.ts"; +import { Setup } from "./Setup.tsx"; +import { Icon } from "./shared.tsx"; +const actions: [Action, string, string][] = [ + ["feed", "feed", "rice ball"], + ["play", "play", "ball"], + ["bath", "bath", "bubbles"], + ["pat", "pat", "heart"], + ["clean", "clean", "sparkle"], + ["medicine", "medicine", "pill"], + ["lights", "lights off", "bulb"], +]; +export function Device() { + const { state: s } = store; + const canvas = useRef(null); + const foods = useRef(null); + const feed = useRef(null); + const [foodOpen, setFoodOpen] = useState(false); + const off = !s.pet || !!view.game || brain.state !== "ready"; + const game = !!view.game; + useEffect(() => (canvas.current ? attachCanvas(canvas.current) : undefined), []); + useEffect(() => { + if (off) setFoodOpen(false); + }, [off]); + useEffect(() => { + if (foodOpen) foods.current?.querySelector("button")?.focus(); + }, [foodOpen]); + useEffect(() => { + if (game) canvas.current?.focus(); + }, [game]); + const words = levelWords(s.stats); + const status = [ + brain.state !== "ready" + ? ["off", "error"].includes(brain.state) + ? "dozing (no brain)" + : "waking up" + : s.asleep + ? "asleep" + : "", + s.sick ? "sick" : "", + s.poop ? `${s.poop} poop${s.poop > 1 ? "s" : ""}` : "", + ].filter(Boolean); + const label = s.pet + ? `${s.pet.name} the ${s.pet.stage}: tummy ${words.tummy}, fun ${words.fun}, energy ${words.energy}, ${words.clean}${s.asleep ? ", asleep" : ""}${s.sick ? ", sick" : ""}` + : "an egg, waiting to hatch"; + return ( +
    +
    + + +
    +

    + {s.pet && + `${s.pet.name} · ${s.pet.stage} · day ${dayNum()} · ${s.pet.traits.join(", ")}${status.length ? " · " + status.join(", ") : ""}`} +

    + + + + +
    + ); +} +function Pad({ direction }: { direction: number }) { + const release = (e: PointerEvent) => gameControl(`pointer:${e.pointerId}`, 0); + return ( + + ); +} diff --git a/web/components/Diary.tsx b/web/components/Diary.tsx new file mode 100644 index 0000000..f516dbc --- /dev/null +++ b/web/components/Diary.tsx @@ -0,0 +1,47 @@ +import { store, ui } from "../app/store.ts"; +import { Icon } from "./shared.tsx"; +export function Diary() { + const s = store.state; + return ( + <> +

    + {s.today.log.length ? `today so far: ${s.today.log.join("; ")}` : ""} +

    +
    + {ui.diaryWriting && ( +
    +
    + {s.pet?.name} is writing… +
    +

    {ui.diaryDraft}

    +
    + )} + {[...s.diary].reverse().map((page) => ( +
    +
    + {`day ${page.day} · ${page.date} · ${page.weather}`} + {page.stage} +
    + {page.text.split(/\n+/).map((text, i) => ( +

    {text}

    + ))} + {!!page.doodles?.length && ( +
    + {page.doodles.map((d, i) => ( + + ))} +
    + )} +
    + ))} + {!s.diary.length && !ui.diaryWriting && ( +

    + {s.pet + ? `${s.pet.name} writes a page when it goes to sleep at night, about a day with at least two things in it. Days it missed get written next time it's awake with you.` + : "The diary starts after hatching."} +

    + )} +
    + + ); +} diff --git a/web/components/Settings.tsx b/web/components/Settings.tsx new file mode 100644 index 0000000..ef5958d --- /dev/null +++ b/web/components/Settings.tsx @@ -0,0 +1,143 @@ +import { getBrain, olderBrain } from "../app/brain.ts"; +import { + deleteBrain, + exportSave, + importSave, + renameOwner, + resetSave, + setChatter, +} from "../app/commands.ts"; +import { CFG } from "../app/config.ts"; +import { installApp, installEvent, isIOS, setAlerts, standalone } from "../app/pwa.ts"; +import { brain, store, ui, upgrade } from "../app/store.ts"; +import { fmtMB } from "../app/utils.ts"; +import { brainStatus } from "./Brain.tsx"; +export function Settings() { + const s = store.state, + st = brain.state, + up = upgrade.state; + const canUpgrade = st === "ready" && olderBrain() && (up === "idle" || up === "error"); + const installing = !standalone() && (!!installEvent || isIOS()); + const alertNote = s.settings.alerts + ? standalone() + ? "Alerts arrive while the app is open or in the background; when it is closed, your device decides how often it may check." + : "Alerts arrive while this tab is open in the background. Installed as an app, they can also come when it is closed, on some devices." + : ui.alertsNote; + return ( + <> +

    Brain

    +

    + {brainStatus()} +

    +