diff --git a/README.md b/README.md index 1176b626..e1d06609 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ These add a page to magpie's window, in the app and in `magpie web`, under Plugi | [usage](packages/usage) | Usage glance: calls, errors, tokens and cost for today, 7 or 30 days, and the models behind them | `usage:summary` | | [powercontext](packages/powercontext) | Connection settings for an independently deployed PowerContext service | None | | [agent-office](packages/agent-office) | What are my agents doing: live sessions as workers in a voxel office (3D/2D) or a list, with status, current step and resume commands | no `magpie.read`; its host part reads the local session files of Claude Code, Codex, dsh, Gemini CLI, Qwen Code, Pi, omp, Qoder, WorkBuddy and CodeBuddy, including prompts, replies and tool commands (`text:false` keeps statuses and tool names only) | -| [chat](packages/chat) | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magiczerowxy](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) | +| [chat](packages/chat) | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magic_zero](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) | +| [digital-pet](packages/magpie-digital-pet) | A virtual pet fed by routed usage: care actions, expeditions, daily goals, decorations, naming and a 650-entry dex | no `magpie.read`; its host part polls magpie's local usage API and stores progress in the user data directory | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magiczerowxy](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) | ```sh magpie plugin add @magpie-community/page- diff --git a/packages/magpie-digital-pet/.gitignore b/packages/magpie-digital-pet/.gitignore new file mode 100644 index 00000000..74a788e3 --- /dev/null +++ b/packages/magpie-digital-pet/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +data/ +.upstream/ +*.log +*.db +*.db-shm +*.db-wal +tests/tmp-selftest.db* +tests/watcher-state.json diff --git a/packages/magpie-digital-pet/LICENSE b/packages/magpie-digital-pet/LICENSE new file mode 100644 index 00000000..3d6428f0 --- /dev/null +++ b/packages/magpie-digital-pet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sergio Bugallo Enjamio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/magpie-digital-pet/NOTICE b/packages/magpie-digital-pet/NOTICE new file mode 100644 index 00000000..3f185822 --- /dev/null +++ b/packages/magpie-digital-pet/NOTICE @@ -0,0 +1,16 @@ +NOTICE for @magpie-community/page-digital-pet +============================================= + +This package is a magpie page/plugin adapter. It does not bundle the upstream +engine, the Digimon catalog, evolution data, or any sprite art. + +The engine is an external npm dependency: +@jcendal/opencode-digital-pet 0.18.0 (Apache License 2.0), by Jorge Cendal +(jcendal) and Sergio Bugallo (sbugallo). At runtime that package is installed +by the package manager; it is not vendored into this repository. + +The sidebar page draws a deterministic procedural pixel blob for each pet. No +sprite frames or other restricted art assets are shipped in this package or in +the npm tarball. + +Licensed under the Apache License, Version 2.0 (see LICENSE). diff --git a/packages/magpie-digital-pet/README.md b/packages/magpie-digital-pet/README.md new file mode 100644 index 00000000..b81a53fb --- /dev/null +++ b/packages/magpie-digital-pet/README.md @@ -0,0 +1,112 @@ +# @magpie-community/page-digital-pet + +烧 token,养电子宠物 —— 跑在 [magpie](https://github.com/yetone/magpie) 网关上的插件,带侧边栏页面。 + +统计**所有 magpie 记账的请求**(Claude Code、Codex、Pi、OpenCode、Gemini CLI……不管哪个 agent、是否流式),把 token 喂给当前出战宠物。失败请求不喂。 + +引擎来自外部依赖 [@jcendal/opencode-digital-pet](https://github.com/jcendal/digital-pet) 0.18.0(Apache-2.0,见 NOTICE),本仓库不 vendored 上游代码、不打包图鉴数据、不打包任何精灵图素材;侧边栏页面使用程序化像素兜底图。 + +## 界面 + +- **侧边栏页面**:magpie 窗口左侧导航里的「宠物」页 —— 首屏是宠物栖息地:像素宠物随状态活动,下方是五项状态条(饱食/心情/精力/清洁/亲密)与原有的进化进度条。可以给当前搭档**起名**,也可以在**图鉴与切换**里搜索 650 只宠物:已解锁的可切换展示,未解锁的灰显为「未解锁」。本轮新玩法入口都在这一页:**日常照顾**(点心/玩耍/清洁/休息/摸蛋,均有专属动画反馈)、三档**探险**(短巡逻/数据原野/深夜远征,结算得数据碎片与随机见闻)、**每日目标**(照顾 2 次、探险 1 次、自动进食 10 个请求)与**装饰收藏**(碎片解锁 8 套栖息地背景,本地成就记录)。原有的当前搭档与进化进度、喂食统计、一键孵蛋/冻结/钉 ID、喂食记录、历代列表全部保留在页面下方,每 5 秒自刷新,主题跟随 magpie 窗口(page-chat 同款带路页跳本地服务) +- **设置面板**:magpie → Settings → Plugins → Digital Pet(开关、缓存 token、补录、轮询间隔) +- **CLI**:见下 + +## 工作方式 + +``` +magpie 网关 ──记账──> /v1/magpie/usage/requests + │ 每 60s 轮询(route_id 幂等去重) + ▼ + 本插件(magpie 的 Bun 宿主进程内) + │ 合成 message.updated 事件 + ▼ + digital-pet 引擎(原版)──> 用户目录存档(Windows: %APPDATA%\magpie-digital-pet\pet.db) + ▲ + 侧边栏页面 <── 本地服务 127.0.0.1:3571(读同一库) +``` + +不走中间件(magpie 的中间件沙箱无 IO),不解析响应体(magpie 已经替你把每个请求的 token 数好了,流式也准)。多个宿主进程同时跑 watcher 也安全:引擎按 route_id 去重,SQLite 走 WAL;页面服务固定端口,谁先绑谁服务,端口被短命宿主占着时后来者定期重试接管。 + +成长阈值使用上游引擎默认值;本插件不写 `opencode-digital-pet.json`,不覆盖上游 OpenCode 插件的全局配置。 + +「孵新蛋」会让当前宠物退役,因此页面要求二次确认,并且同一颗新蛋 60 秒内不能连续孵化。当前伙伴必须达到 baby II 才能孵下一颗蛋;首颗蛋是 Digiegg,之后孵化池按图鉴顺序选择你还没养过的 baby 形态,不重复孵化。token 只喂给当前出战伙伴,不会平均分给历代或后备宠物。图鉴里的「切换」会展示并钉住该形态(暂停生长),管理区的「取消钉住」可随时恢复。 + +## 安装 + +```sh +magpie plugin add E:\LLM_Config\Magpie\plugins\magpie-digital-pet +``` + +装好即开始记账喂食,首次自动孵蛋,侧边栏「宠物」页即开即用。命令行等效操作: + +```sh +cd E:\LLM_Config\Magpie\plugins\magpie-digital-pet +bun cli\pet-cli.mjs status # 当前进度、进化百分比(与侧边栏页同源),另附四行只读养成摘要: + # care(四项状态)/ bond(亲密)/ fragments(数据碎片)/ expedition(进行中探险) +bun cli\pet-cli.mjs spawn # 孵新蛋 +bun cli\pet-cli.mjs events # 最近事件 +bun cli\pet-cli.mjs partners # 养过的每一代 +bun cli\pet-cli.mjs freeze # 暂停生长(用量照记,宠物不长) +bun cli\pet-cli.mjs unfreeze +bun cli\pet-cli.mjs set 4-001 # 钉死某只(cheat,钉住时不再生长) +``` + +CLI 只提供只读摘要;日常照顾、探险和装饰装备等写操作请用侧边栏「宠物」页。 + +## 配置 + +| 选项 | 默认 | 说明 | +|---|---|---| +| `enabled` | `true` | 总开关 | +| `notifications` | `true` | 进化/孵化提示写进 magpie 插件日志 | +| `includeCacheTokens` | `false` | cache 命中是否计入。默认不计 | +| `backfill` | `false` | 补录 magpie 已记的 30 天用量(蛋立刻孵化)。改后需重载插件 | +| `pollSeconds` | `60` | 轮询间隔(15–3600s)。改后需重载插件 | +| `gatewayUrl` | `http://127.0.0.1:3425` | 网关地址 | +| `dbPath` | `%APPDATA%\magpie-digital-pet\pet.db`(Windows) | 个人存档路径;默认与插件代码目录分离,更新插件不会动存档。可显式覆盖 | +| `port` | `3571` | 侧边栏页面服务的本地端口 | + +## 数据 + +- 数据库:Windows 默认 `%APPDATA%\magpie-digital-pet\pet.db`(SQLite,WAL,与 CLI / 页面并发安全);其他平台为 `~/.config/magpie-digital-pet/pet.db`。首次启动会自动从旧版 `<插件目录>\data\pet.db` 迁移,旧文件保留作备份,不删除。 +- 轮询游标:存档同目录 `watcher-state.json`(删掉它 + 开 `backfill` 可重算历史) + +## 红线(与策划书一致) + +- 不注入任何 agent 的上下文:不注册 hooks/MCP/skills,不写 agent 配置 +- 不消耗 token:引擎零 LLM 调用,全部本地规则 +- 不改请求:只读 magpie 的用量账本 +- 侧边栏页面只绑 127.0.0.1,只服务 magpie 窗口跳来的页面 +- fail-open:本插件任何异常都不影响网关 + +## 测试 + +```sh +bun tests/pet-frames.test.mjs # 像素帧提取与加载 +bun tests/pet-care-rules.test.mjs # 照顾状态与主动操作规则 +bun tests/pet-progression-rules.test.mjs # 探险、每日目标与成就规则 +bun tests/pet-care-store.test.mjs # 养成 SQLite 存储 +bun tests/pet-server-care.test.mjs # 页面 API 与安全 +bun tests/page-contract.test.mjs # 页面结构契约 +bun tests/docs-contract.test.mjs # 文档与 CLI 摘要契约 +bun tests/e2e-page.test.mjs # 真实存档副本端到端;浏览器段见下 +bun tests/selftest.mjs # 26 项离线自检(假网关 + 临时库 + 页面服务 + 端口接管) +``` + +端到端测试的核心段始终运行;真实浏览器段只有显式提供本机 Playwright Core 与浏览器路径时才运行,并在临时目录保存截图: + +```sh +PET_E2E_PLAYWRIGHT="" \ +PET_E2E_EDGE="" \ +PET_E2E_ARTIFACT_DIR="<截图输出目录>" \ +bun tests/e2e-page.test.mjs +``` + +## 卸载 + +```sh +magpie plugin rm magpie-digital-pet +``` + +个人数据留在用户目录(Windows 为 `%APPDATA%\magpie-digital-pet\`),与插件代码目录分开;想全删手动删除该目录即可。旧版插件目录下的 `data\` 只保留迁移前备份。 diff --git a/packages/magpie-digital-pet/UPSTREAM-CHANGELOG.md b/packages/magpie-digital-pet/UPSTREAM-CHANGELOG.md new file mode 100644 index 00000000..ed5d7b76 --- /dev/null +++ b/packages/magpie-digital-pet/UPSTREAM-CHANGELOG.md @@ -0,0 +1,192 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.18.0] - 2026-10-29 + +### Changed + +- feat: add LCD and Color themes with web integration + + +## [0.17.0] - 2026-10-29 + +### Changed + +- Feat/multiplayer product design + + +## [0.16.0] - 2026-10-21 + +- Repair DoneDevimon's identity, missing LoaderLeomon/Crowmon branches and eight pair-only routes without adding species or changing saved IDs. +- Count duplicate DORUguremon IDs once in automatic evolution selection, preserve legacy pending duels, and let final forms keep earning EXP and fighting threshold duels. + +### Changed + +- Adapt device evolution victory minimums to game pacing (12→3, 24→5, 50→7, 200→10) in shared rules and the Dex, preserving care alternatives and existing counters. +- Use locally bundled, species-specific projectiles in shared battles: all 109 PENOG sprites and reviewed assignments for 526 catalog entries; retain the existing fallback where source data is missing. + +## [0.15.1] - 2026-10-15 + +### Changed + +- Restore Lopmon and other omitted evolution branches from reviewed device detail cards, keep fusion/special routes out of ordinary EXP selection and preserve withdrawn targets for pending saves. +- Give each companion its own adventure cooldown, preserve the principal's legacy timer and keep adventure waits advancing in reserve while apple and care timers pause. + +## [0.15.0] - 2026-10-14 + +### Changed + +- Start the evolution duel at 100% even without an eligible route. Count a win before choosing the target; without a result, retain the companion and victory, show the notice and reset EXP. +- Persist adventure and evolution-duel victories per form, prevent replayed rewards and show numeric Dex requirements only on applicable routes. +- Prepare shared SQLite team persistence for two owned companions, preserving the existing active pet during migration and keeping token receipts on the principal. Team controls are not exposed yet. +- Choose experience evolutions from referenced normal routes according to the current 0–3 unclean piles. Exclude Jogress and unverified routes from automatic evolution. + +## [0.14.0] - 2026-10-11 + +### Changed + +- Feat/music loop sections + + +## [0.13.0] - 2026-10-11 + +### Changed + +- Feat/adventure pixel controls + + +## [0.12.0] - 2026-10-11 + +### Changed + +- feat: add adventure bosses and refine pet care UI + + +## [0.11.0] - 2026-10-11 + +### Changed + +- feat: add persistent adventures and companion recruitment + + +## [0.10.0] - 2026-10-11 + +### Changed + +- feat: add pet affection, scale battle rewards and refine Dex UI + + +## [0.9.0] - 2026-10-10 + +### Changed + +- feat: add egg petting rewards in web and Cursor + + +## [0.8.0] - 2026-10-10 + +### Changed + +- feat: add shared translations and persistent web language selection + + +## [0.7.0] - 2026-10-10 + +### Changed + +- feat: add player battles and shared pet care + + +## [0.6.0] - 2026-10-09 + +### Changed + +- Feat/gameplay combat evolution feeding + + +## [0.5.0] - 2026-10-07 + +### Changed + +- feat(web): version and tag site releases like the other products + + +## [0.4.0] - 2026-10-07 + +### Changed + +- feat(ci): dispatch web deploy from merged release plan + + +## [0.3.0] - 2026-10-06 + +### Changed + +- Feat/cursor pet display + + +## [0.2.3] - 2026-10-06 + +### Changed + +- chore(digital-pet-core): mark stages for a release probe + + +## [0.2.2] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.1] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.0] - 2026-10-06 + +### Changed + +- Renamed the product and packages to Digital Pet under the `jcendal` namespace. +- Kept existing `opencode-vpet` settings and database paths readable so current partners remain available. +- Updated release metadata for the `jcendal/digital-pet` repository and credited Sergio Bugallo as the original author. + +## [0.1.1] - 2026-08-30 + +### Fixed + +- Exported the OpenCode server plugin entrypoint so VPet commands and the sidebar load correctly. +- Updated `init` and `update` to delegate to OpenCode's native global plugin installer with the + installed exact package version, avoiding stale bare or `@latest` cache reuse. + +## [0.1.0] - 2026-08-30 + +### Added + +- Server and TUI plugins that add a Digimon virtual pet to the OpenCode sidebar. +- Progression from completed assistant-message token usage, including reconciliation of completed + usage from concurrent sessions. +- Toast notifications for spawning, progression controls, and Digimon evolutions. +- A 650-Digimon catalog spanning eight evolution stages, with evolution paths and Japanese and + English names. +- Partner generations that can be spawned, evolved, frozen, unfrozen, or set to a catalog ID. +- Sidebar progress and status display, including frozen and manually set partner states, with + animated walking, action, and sleep behavior. +- Dex and History dialogs for browsing discovered Digimon and current or retired partner + generations, with links to external encyclopedia entries. +- `/vpet-spawn`, `/vpet-freeze`, `/vpet-unfreeze`, `/vpet-set `, `/vpet-dex`, and + `/vpet-history` commands. +- SQLite persistence for partners, progression events, usage receipts, trainer totals, and control + state. +- Global settings for language, evolution thresholds, and notifications. +- `npx @sbugallo/opencode-vpet init` and `npx @sbugallo/opencode-vpet update` commands that register + server and TUI plugins while preserving unrelated JSON and JSONC configuration. diff --git a/packages/magpie-digital-pet/cli/pet-cli.mjs b/packages/magpie-digital-pet/cli/pet-cli.mjs new file mode 100644 index 00000000..ac9a6a98 --- /dev/null +++ b/packages/magpie-digital-pet/cli/pet-cli.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env bun +// magpie-digital-pet · CLI +// Run with bun (the engine uses bun:sqlite): +// bun cli/pet-cli.mjs status +// bun cli/pet-cli.mjs spawn # hatch a new egg (generation N) +// bun cli/pet-cli.mjs freeze # stop growth (usage no longer counts) +// bun cli/pet-cli.mjs unfreeze +// bun cli/pet-cli.mjs set 4-001 # pin a specific Digimon (cheat) +// bun cli/pet-cli.mjs events [n] # last n engine events (default 10) +// bun cli/pet-cli.mjs partners # every generation you raised + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { Database } from "bun:sqlite"; + +const HERE = import.meta.dir; +const ADAPTER = join(HERE, "..", "lib", "pet.adapter.mjs"); +const { createPetAdapter, nodeById, defaultDatabasePath, migrateLegacyDatabase } = await import(ADAPTER); +const { createCareStore } = await import(join(HERE, "..", "lib", "pet.care-store.mjs")); + +const STAGE_NAMES = ["egg", "baby I", "baby II", "child", "adult", "perfect", "ultimate", "super ultimate"]; +const STAGE_THRESHOLDS = [5e6, 1e7, 2e7, 4e7, 75e6, 125e6, 2e8, 3e8]; + +const argv = process.argv.slice(2); +const dbFlagIndex = argv.indexOf("--db"); +const dbPath = dbFlagIndex !== -1 ? argv[dbFlagIndex + 1] : defaultDatabasePath(); +if (dbFlagIndex !== -1) argv.splice(dbFlagIndex, 2); +else migrateLegacyDatabase(dbPath); + +function fmtTokens(n) { + if (n >= 1e8) return `${(n / 1e8).toFixed(2)}亿`; + if (n >= 1e4) return `${(n / 1e4).toFixed(1)}万`; + return String(n); +} + +function nodeLabel(id) { + const node = nodeById(id); + if (!node) return `${id} (unknown)`; + return `${node.name_en} · ${node.name_jp} [${id}]`; +} + +function openReadOnly() { + if (!existsSync(dbPath)) return null; + const db = new Database(dbPath, { readonly: true }); + db.run("PRAGMA busy_timeout = 5000"); + return db; +} + +const EXPEDITION_KIND_NAMES = { patrol: "短巡逻", field: "数据原野", night: "深夜远征" }; + +// Read-only care summary for `status`. Uses the care store's read APIs only +// (getCareSnapshot/getCollection) and always closes the store afterwards; +// care and expedition writes stay on the sidebar pet page. +function printCareSummary(partnerId) { + const store = createCareStore({ databasePath: dbPath }); + try { + const nowMs = Date.now(); + const { state, summary, activeExpedition } = store.getCareSnapshot(partnerId, nowMs); + const collection = store.getCollection(); + const r = (v) => Math.round(v); + console.log(`care 饱食 ${r(state.satiety)} · 心情 ${r(state.mood)} · 精力 ${r(state.energy)} · 清洁 ${r(state.hygiene)}(${summary.moodLabel})`); + console.log(`bond 亲密 ${r(state.bond)}`); + console.log(`fragments 数据碎片 ${collection.fragments} · 装饰 ${collection.unlocked.length} 件 · 成就 ${collection.achievements.length} 项`); + if (activeExpedition) { + const kind = EXPEDITION_KIND_NAMES[activeExpedition.kind] ?? activeExpedition.kind; + const remainMin = Math.ceil(Math.max(0, activeExpedition.endsAt - nowMs) / 60000); + console.log(`expedition ${kind}进行中 · 剩余约 ${remainMin} 分钟`); + } else { + console.log(`expedition 无进行中探险(去侧边栏「宠物」页派出)`); + } + } finally { + store.close(); + } +} + +function status() { + const db = openReadOnly(); + if (!db) { + console.log(`no database yet at ${dbPath}`); + console.log("run `bun cli/pet-cli.mjs spawn` to hatch your first partner."); + return; + } + const trainer = db.query("SELECT total_tokens FROM trainer_state WHERE trainer_id = 1").get(); + const control = db.query("SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1").get(); + const partner = db + .query( + `SELECT partner_id, generation, current_node_id, gauge, is_terminal, + pending_evolution_target_id, battle_opponent_node_id, created_at, retired_at + FROM partners + WHERE partner_id = (SELECT principal_id FROM partner_team WHERE team_id = 1) + AND retired_at IS NULL LIMIT 1`, + ) + .get(); + const receipts = db.query("SELECT COUNT(*) c, SUM(token_delta) t FROM usage_receipts").get(); + + console.log(`db ${dbPath}`); + console.log(`fed ${receipts.c} requests · ${fmtTokens(receipts.t ?? 0)} tokens(累计)`); + console.log(`trainer ${fmtTokens(trainer?.total_tokens ?? 0)} tokens`); + if (control?.cheat_node_id) { + console.log(`mode pinned to ${nodeLabel(control.cheat_node_id)} (set; no growth)`); + } else if (control?.frozen === 1) { + console.log(`mode frozen (usage is recorded, pet does not grow)`); + } + if (!partner) { + console.log(`partner none — run \`spawn\``); + } else { + const node = nodeById(partner.current_node_id); + const stage = node?.stage ?? 0; + const threshold = STAGE_THRESHOLDS[stage] ?? 0; + const pct = threshold > 0 ? ((partner.gauge / threshold) * 100).toFixed(2) : "?"; + console.log(`partner gen ${partner.generation} · ${nodeLabel(partner.current_node_id)}`); + console.log(` stage ${STAGE_NAMES[stage] ?? stage} · 本阶段 ${fmtTokens(partner.gauge)} / ${fmtTokens(threshold)} (${pct}%)`); + if (partner.battle_opponent_node_id) { + console.log(` evolution battle pending vs ${nodeLabel(partner.battle_opponent_node_id)}`); + } + printCareSummary(partner.partner_id); + } + db.close(); +} + +async function command(name, args) { + const adapter = createPetAdapter({ databasePath: dbPath }); + const result = await adapter.runCommand(name, args); + if (result.text) console.log(result.text); + for (const t of result.toasts) { + console.log(`» ${[t?.title, t?.message].filter(Boolean).join(" — ")}`); + } + if (!result.text && !result.toasts.length) console.log("(no output)"); +} + +function events(n = 10) { + const db = openReadOnly(); + if (!db) return console.log("no database yet."); + const rows = db + .query( + `SELECT event_id, partner_id, kind, current_node_id, gauge, token_delta, created_at + FROM partner_events ORDER BY created_at DESC, event_id DESC LIMIT ?`, + ) + .all(n); + for (const r of rows) { + console.log( + `${r.created_at} ${r.kind.padEnd(16)} ${nodeById(r.current_node_id)?.name_en ?? r.current_node_id} gauge=${fmtTokens(r.gauge ?? 0)} +${fmtTokens(r.token_delta ?? 0)}`, + ); + } + db.close(); +} + +function partners() { + const db = openReadOnly(); + if (!db) return console.log("no database yet."); + const rows = db + .query( + `SELECT partner_id, generation, current_node_id, is_terminal, created_at, retired_at + FROM partners ORDER BY generation ASC`, + ) + .all(); + for (const r of rows) { + const state = r.retired_at ? `retired ${r.retired_at.slice(0, 10)}` : "active"; + console.log(`gen ${String(r.generation).padEnd(3)} ${nodeLabel(r.current_node_id)} ${state}`); + } + db.close(); +} + +const [cmd, ...rest] = argv; +switch (cmd) { + case "status": + status(); + break; + case "spawn": + await command("digital-pet-spawn"); + status(); + break; + case "freeze": + await command("digital-pet-freeze"); + break; + case "unfreeze": + await command("digital-pet-unfreeze"); + break; + case "set": + if (!rest[0]) { + console.log("usage: set e.g. set 4-001"); + process.exit(1); + } + await command("digital-pet-set", rest[0]); + break; + case "events": + events(Number(rest[0]) > 0 ? Math.min(Number(rest[0]), 100) : 10); + break; + case "partners": + partners(); + break; + default: + console.log(`magpie-digital-pet CLI +db: ${dbPath} + + status pet, gauge, progress and read-only care summary + spawn hatch a new egg + freeze | unfreeze stop / resume growth + set pin a Digimon (cheat; no growth while pinned) + events [n] last n engine events + partners every generation raised + --db use another database`); + process.exit(cmd ? 1 : 0); +} diff --git a/packages/magpie-digital-pet/index.mjs b/packages/magpie-digital-pet/index.mjs new file mode 100644 index 00000000..081e03fd --- /dev/null +++ b/packages/magpie-digital-pet/index.mjs @@ -0,0 +1,72 @@ +// magpie-digital-pet · plugin entry +// +// Loaded by magpie's Bun host as an OpenCode v1 plugin (the page-chat +// pattern): the entry registers no providers and no tools — it borrows the +// host process to run +// 1. the pet watcher: polls magpie's usage API and feeds the engine +// 2. the pet page server: a local HTTP server the sidebar page navigates to +// +// Nothing is injected into any agent's context; requests are never modified. + +import { createPetAdapter } from "./lib/pet.adapter.mjs"; +import { createPetWatcher } from "./lib/pet.watcher.mjs"; +import { createPetServer } from "./lib/pet.server.mjs"; + +const DEFAULTS = { + enabled: true, + notifications: true, + includeCacheTokens: false, + backfill: false, + pollSeconds: 60, + gatewayUrl: "http://127.0.0.1:3425", + dbPath: "", + port: 3571, +}; + +let watcher = null; +let server = null; +let starting = null; + +async function startAll(options) { + const opts = { ...DEFAULTS, ...(options && typeof options === "object" ? options : {}) }; + if (watcher) { + await watcher.stop(); + watcher = null; + } + if (server) { + await server.close(); + server = null; + } + if (opts.enabled === false) { + console.log("[magpie-digital-pet] disabled by options"); + return; + } + const dbPath = typeof opts.dbPath === "string" && opts.dbPath.trim() ? opts.dbPath.trim() : undefined; + const port = Number.isFinite(Number(opts.port)) && Number(opts.port) >= 0 ? Number(opts.port) : 3571; + + const adapter = createPetAdapter({ dbPath, notifyGate: () => opts.notifications !== false }); + server = createPetServer({ adapter, port }); + watcher = createPetWatcher({ + adapter, + gatewayUrl: opts.gatewayUrl, + pollSeconds: opts.pollSeconds, + includeCacheTokens: opts.includeCacheTokens === true, + notifications: opts.notifications !== false, + backfill: opts.backfill === true, + }); + await server.start(); + watcher.start(); +} + +export default { + id: "magpie-digital-pet", + server: async (_input, options) => { + // the host calls this on load (and again after a hot reload with new + // options) — (re)start everything there; errors never fail the load + starting = startAll(options).catch((e) => { + console.log("[magpie-digital-pet] start failed:", e?.message ?? e); + }); + await starting.catch(() => null); + return {}; + }, +}; diff --git a/packages/magpie-digital-pet/lib/pet.adapter.mjs b/packages/magpie-digital-pet/lib/pet.adapter.mjs new file mode 100644 index 00000000..7268817c --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.adapter.mjs @@ -0,0 +1,277 @@ +// magpie-digital-pet · engine adapter +// +// The upstream @jcendal/opencode-digital-pet 0.18.0 bundle (lib/upstream/index.js, +// Apache-2.0) exports an OpenCode plugin factory `plugin(input, options)` that +// returns server hooks. We drive that engine from magpie's gateway middleware: +// +// - a fake `input.client` whose tui.showToast logs to the plugin log channel +// and whose session.messages never returns anything (we never emit +// "session.idle" events); +// - usage is fed by synthesizing the OpenCode "message.updated" event shape +// the engine already knows how to parse; +// - commands (spawn/freeze/unfreeze/set) go through the engine's +// "command.execute.before" hook, the same path OpenCode's TUI uses. +// +// Everything below this comment is adapter code; all game logic, catalog data +// (650 Digimon, 2606 evolution routes), SQLite schema and migrations are the +// upstream engine, untouched. + +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { Database } from "bun:sqlite"; +import { plugin as createOpenCodePetPlugin, DIGIMON_DATA } from "@jcendal/opencode-digital-pet"; + +export { DIGIMON_DATA }; + +const num = (v) => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.min(Math.round(n), Number.MAX_SAFE_INTEGER) : 0; +}; + +export function userDataDir() { + if (process.platform === "win32") { + const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); + return join(appData, "magpie-digital-pet"); + } + return join(homedir(), ".config", "magpie-digital-pet"); +} + +export function legacyDatabasePath() { + const here = dirname(fileURLToPath(import.meta.url)); // .../lib + return join(dirname(here), "data", "pet.db"); +} + +export function defaultDatabasePath() { + // Personal progress lives outside the plugin package, so updating the game + // code never touches the save. dbPath remains an explicit override. + return join(userDataDir(), "pet.db"); +} + +export function migrateLegacyDatabase(target = defaultDatabasePath()) { + if (existsSync(target)) return false; + const legacy = legacyDatabasePath(); + if (!existsSync(legacy)) return false; + mkdirSync(dirname(target), { recursive: true }); + const src = new Database(legacy, { readonly: true }); + try { + src.exec(`VACUUM INTO ${JSON.stringify(target)}`); + } finally { + src.close(); + } + const legacyWatcher = join(dirname(legacy), "watcher-state.json"); + const targetWatcher = join(dirname(target), "watcher-state.json"); + if (existsSync(legacyWatcher) && !existsSync(targetWatcher)) copyFileSync(legacyWatcher, targetWatcher); + return true; +} + +// name lookup for status output (raw catalog data, engine-shaped) +export function nodeById(id) { + if (typeof id !== "string") return undefined; + return DIGIMON_DATA.find((entry) => entry.id === id); +} + +export function createPetAdapter({ databasePath, dbPath, notifyGate } = {}) { + const explicit = (typeof databasePath === "string" && databasePath.trim()) + || (typeof dbPath === "string" && dbPath.trim()); + const raw = explicit + ? (typeof databasePath === "string" && databasePath.trim() ? databasePath.trim() : dbPath.trim()) + : defaultDatabasePath(); + const db = raw; + mkdirSync(dirname(db), { recursive: true }); + if (!explicit) migrateLegacyDatabase(db); + + const toasts = []; + const gate = typeof notifyGate === "function" ? notifyGate : () => true; + + const fakeClient = { + tui: { + // OpenCode's toast channel; magpie has none for gateway middleware, so + // the toast text is mirrored to the plugin log instead. + showToast: async (payload) => { + if (gate()) { + const source = payload && typeof payload.body === "object" && payload.body !== null ? payload.body : payload; + toasts.push(source); + try { + const title = typeof source?.title === "string" ? source.title : ""; + const message = typeof source?.message === "string" ? source.message : typeof source?.body === "string" ? source.body : ""; + const line = `[magpie-digital-pet] ${[title, message].filter(Boolean).join(" — ")}`.trim(); + if (line.length > "[magpie-digital-pet] ".length) console.log(line); + } catch {} + } + return { data: true }; + }, + }, + session: { + // never reached: we never emit session.idle events + messages: async () => ({ data: [] }), + }, + }; + + const ready = createOpenCodePetPlugin({ client: fakeClient }, { databasePath: db }).then((hooks) => { + if (typeof hooks?.event !== "function" || typeof hooks?.["command.execute.before"] !== "function") { + throw new Error("upstream engine returned unexpected hooks"); + } + return hooks; + }); + + // read-only mirror connection for receipt lookups (the engine keeps its + // own read-write handle; WAL makes concurrent readers safe) + let roDb = null; + function receiptSeen(receiptKey) { + try { + if (!roDb) { + if (!existsSync(db)) return false; + roDb = new Database(db, { readonly: true }); + roDb.run("PRAGMA busy_timeout = 5000"); + } + const a = roDb.query("SELECT 1 FROM usage_receipts WHERE receipt_key = ? LIMIT 1").get(receiptKey); + if (a) return true; + const b = roDb.query("SELECT 1 FROM vpet_control_receipts WHERE receipt_key = ? LIMIT 1").get(receiptKey); + return !!b; + } catch { + return false; // fail-open: let the engine's own dedupe decide + } + } + + // Starter pool: the first egg is Digiegg. Later hatches pick the first + // baby form the player has not owned yet, so hatching does not repeat until + // the available baby pool is exhausted. Later stages still come from + // evolution, not from the hatch pool. + function applyStarterPool() { + try { + const rw = new Database(db); + rw.run("PRAGMA busy_timeout = 5000"); + try { + const active = rw + .query("SELECT partner_id, current_node_id FROM partners WHERE partner_id = (SELECT principal_id FROM partner_team WHERE team_id = 1) AND retired_at IS NULL LIMIT 1") + .get(); + if (!active || active.current_node_id !== "0-001") return null; + const owned = new Set( + rw.query("SELECT DISTINCT current_node_id FROM partners").all().map((row) => row.current_node_id), + ); + const stageOf = (id) => DIGIMON_DATA.find((entry) => entry.id === id)?.stage ?? 0; + const hasOwnedBabyForm = [...owned].some((id) => stageOf(id) >= 1); + if (!hasOwnedBabyForm) return null; + const picked = + DIGIMON_DATA.find((entry) => entry.stage === 1 && !owned.has(entry.id)) || + DIGIMON_DATA.find((entry) => entry.stage === 2 && !owned.has(entry.id)); + if (!picked) return null; + rw.query("UPDATE partners SET current_node_id = ?, gauge = 0 WHERE partner_id = ?").run(picked.id, active.partner_id); + const spawnEvent = rw + .query("SELECT event_id FROM partner_events WHERE partner_id = ? AND kind = 'spawned' ORDER BY created_at DESC LIMIT 1") + .get(active.partner_id); + if (spawnEvent) { + rw.query("UPDATE partner_events SET current_node_id = ? WHERE event_id = ?").run(picked.id, spawnEvent.event_id); + } + return picked.id; + } finally { + rw.close(); + } + } catch { + return null; + } + } + + const adapter = { + databasePath: db, + lastToasts(n = 5) { + return toasts.slice(-n); + }, + + // Feed one completed request's token usage into the engine. + // tokens: { input, output, reasoning, cacheRead, cacheWrite } (already + // filtered by the includeCacheTokens option upstream of this call). + async feedUsage({ id, tokens = {}, cost, completedAt } = {}) { + const input = num(tokens.input); + const output = num(tokens.output); + const reasoning = num(tokens.reasoning); + const cacheRead = num(tokens.cacheRead); + const cacheWrite = num(tokens.cacheWrite); + const total = input + output + reasoning + cacheRead + cacheWrite; + if (total <= 0) return { kind: "skipped" }; + + const messageId = typeof id === "string" && id.length > 0 ? id.slice(0, 200) : `magpet-${randomUUID()}`; + const receiptKey = `message:${messageId}`; + if (receiptSeen(receiptKey)) return { kind: "duplicate", tokenDelta: total }; + const info = { + role: "assistant", + id: messageId, + time: { completed: Number.isFinite(completedAt) ? completedAt : Date.now() }, + tokens: { input, output, reasoning, cache: { read: cacheRead, write: cacheWrite } }, + cost: Number.isFinite(cost) && cost > 0 ? cost : 0, + }; + + const hooks = await ready; + const before = toasts.length; + await hooks.event({ event: { type: "message.updated", properties: { info } } }); + // the engine tells us nothing back — verify the receipt landed to tell + // "fed" apart from "no active partner" (usage before the first spawn + // is not recorded by the engine, by design) + const stored = receiptSeen(receiptKey); + return { + kind: stored ? "fed" : "no_partner", + tokenDelta: total, + toasts: toasts.slice(before), + }; + }, + + // true when the engine has an active partner (a fresh install has none, + // and usage without one is not recorded) + hasActivePartner() { + try { + if (!existsSync(db)) return false; + if (!roDb) { + roDb = new Database(db, { readonly: true }); + roDb.run("PRAGMA busy_timeout = 5000"); + } + const row = roDb + .query( + "SELECT 1 FROM partners WHERE partner_id = (SELECT principal_id FROM partner_team WHERE team_id = 1) AND retired_at IS NULL LIMIT 1", + ) + .get(); + return !!row; + } catch { + return false; + } + }, + + // Run one of the engine's slash commands: digital-pet-spawn, + // digital-pet-freeze, digital-pet-unfreeze, digital-pet-set . + async runCommand(name, args) { + const hooks = await ready; + const messageID = `digital-pet-${name}-magpie-${randomUUID()}`; + const output = { parts: [] }; + const before = toasts.length; + await hooks["command.execute.before"]( + { command: name, sessionID: "magpie-cli", arguments: args }, + output, + ); + let text = output.parts.map((p) => (typeof p?.text === "string" ? p.text : "")).join("\n").trim(); + if (name === "digital-pet-spawn") { + const starterId = applyStarterPool(); + if (starterId) { + const starter = nodeById(starterId); + const label = starter ? `${starter.name_en} · ${starter.name_jp}` : starterId; + text = `${text}\n孵化池起效了:这次孵出的是 ${label}。`; + toasts.push({ title: "孵化池", message: `孵出了 ${label}` }); + } + } + return { + text, + toasts: toasts.slice(before), + }; + }, + }; + + // surface init errors to the first caller instead of dead-ending + const wrap = (fn) => async (...a) => { + await ready; + return fn(...a); + }; + adapter.feedUsage = wrap(adapter.feedUsage); + adapter.runCommand = wrap(adapter.runCommand); + return adapter; +} diff --git a/packages/magpie-digital-pet/lib/pet.care-rules.mjs b/packages/magpie-digital-pet/lib/pet.care-rules.mjs new file mode 100644 index 00000000..cbeb6d83 --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.care-rules.mjs @@ -0,0 +1,206 @@ +// lib/pet.care-rules.mjs +// Pure care rules for the Magpie digital pet: decay, actions, summary. +// No database access, no Date.now(); every timestamp comes from the caller +// and every function returns a new state object. + +export const CARE_LIMITS = { min: 0, max: 100, maxOfflineMs: 24 * 60 * 60 * 1000 }; + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; + +export const CARE_ACTIONS = { + snack: { cooldownMs: 10 * MINUTE, effects: { satiety: 10, mood: 2 } }, + play: { cooldownMs: 8 * MINUTE, minEnergy: 20, effects: { mood: 12, bond: 2, energy: -10 } }, + clean: { cooldownMs: 10 * MINUTE, setHygiene: 100, bondBonusBelowHygiene: 50, bondBonus: 1 }, + rest: { cooldownMs: 15 * MINUTE, effects: { energy: 25, satiety: -2 } }, + petEgg: { cooldownMs: 5 * MINUTE, stageOnly: 0, effects: { bond: 2 } }, +}; + +// Per-hour drift while time passes; bond never decays. +const DECAY_PER_HOUR = { satiety: -2, mood: -1.5, energy: 3, hygiene: -1 }; + +function clamp(value) { + if (value < CARE_LIMITS.min) return CARE_LIMITS.min; + if (value > CARE_LIMITS.max) return CARE_LIMITS.max; + return value; +} + +export function createDefaultCareState(partnerId, nowMs) { + return { + partnerId, + satiety: 72, + mood: 70, + energy: 80, + hygiene: 75, + bond: 0, + cooldowns: { snack: 0, play: 0, clean: 0, rest: 0, petEgg: 0 }, + lastDecayAt: nowMs, + updatedAt: nowMs, + }; +} + +export function decayCareState(state, nowMs) { + const elapsed = Math.max(0, nowMs - state.lastDecayAt); + const capped = Math.min(elapsed, CARE_LIMITS.maxOfflineMs); + const hours = capped / HOUR; + // The decay amount is capped at 24h, but the clock always advances to nowMs + // so the skipped time is never decayed twice. + return { + ...state, + satiety: clamp(state.satiety + DECAY_PER_HOUR.satiety * hours), + mood: clamp(state.mood + DECAY_PER_HOUR.mood * hours), + energy: clamp(state.energy + DECAY_PER_HOUR.energy * hours), + hygiene: clamp(state.hygiene + DECAY_PER_HOUR.hygiene * hours), + cooldowns: { ...state.cooldowns }, + lastDecayAt: state.lastDecayAt + elapsed, + updatedAt: nowMs, + }; +} + +const ACTION_MESSAGES = { + snack: "吃点心真开心!", + play: "玩得好尽兴!", + clean: "变得干干净净了。", + rest: "好好休息了一下。", + petEgg: "轻轻摸了摸蛋壳。", +}; + +export function applyCareAction(state, action, nowMs, context = {}) { + const def = CARE_ACTIONS[action]; + if (!def) { + return { ok: false, state, message: "不认识这个操作。", reason: "unknown-action" }; + } + if ((state.cooldowns[action] ?? 0) > nowMs) { + return { ok: false, state, message: "还在冷却中,稍等一会儿吧。", reason: "cooldown" }; + } + if (def.stageOnly !== undefined && context.stage !== def.stageOnly) { + return { ok: false, state, message: "只有蛋阶段才能这样做。", reason: "wrong-stage" }; + } + if (def.minEnergy !== undefined && state.energy < def.minEnergy) { + return { ok: false, state, message: "它现在太困了,先休息一下。", reason: "low-energy" }; + } + + const next = { ...state, cooldowns: { ...state.cooldowns }, updatedAt: nowMs }; + if (def.effects) { + for (const [key, delta] of Object.entries(def.effects)) { + next[key] = clamp(next[key] + delta); + } + } + if (def.setHygiene !== undefined) { + if (state.hygiene < def.bondBonusBelowHygiene) { + next.bond = clamp(next.bond + def.bondBonus); + } + next.hygiene = def.setHygiene; + } + next.cooldowns[action] = nowMs + def.cooldownMs; + return { ok: true, state: next, message: ACTION_MESSAGES[action] }; +} + +export function careSummary(state) { + const score = (state.satiety + state.mood + state.energy + state.hygiene) / 4; + const moodLabel = + score >= 75 ? "神采奕奕" : score >= 50 ? "状态不错" : score >= 25 ? "有点没精神" : "需要照顾"; + const needsAttention = [state.satiety, state.mood, state.energy, state.hygiene].some((v) => v < 25); + return { moodLabel, needsAttention }; +} + +// --- Expeditions, daily goals and achievements (Task 3) --- + +export const EXPEDITIONS = { + patrol: { durationMs: 300_000, fragments: 3, bond: 1 }, + field: { durationMs: 900_000, fragments: 8, bond: 2 }, + night: { durationMs: 1_800_000, fragments: 15, bond: 3 }, +}; + +const EXPEDITION_ENERGY_COST = 10; +const EXPEDITION_MIN_ENERGY = 15; + +const EXPEDITION_NOTES = [ + "它在草丛里发现了一枚闪闪发光的数据碎片。", + "它对着路过的比特兽友好地打了个招呼。", + "它在数据原野上滚了一圈,浑身都是静电。", + "它找到了一条捷径,得意地绕着你转圈。", + "它捡到了一段奇怪的代码,看起来像藏宝图。", + "它在夜里看到了流星,悄悄许了个愿。", +]; + +export function startExpedition({ careState, activeExpedition, kind, nowMs }) { + const def = EXPEDITIONS[kind]; + if (!def) { + return { ok: false, careState, reason: "unknown-expedition" }; + } + if (activeExpedition && activeExpedition.status === "running") { + return { ok: false, careState, reason: "expedition-active" }; + } + if (careState.energy < EXPEDITION_MIN_ENERGY) { + return { ok: false, careState, reason: "low-energy" }; + } + const nextState = { + ...careState, + cooldowns: { ...careState.cooldowns }, + energy: clamp(careState.energy - EXPEDITION_ENERGY_COST), + updatedAt: nowMs, + }; + const expedition = { + expeditionId: `exp-${careState.partnerId}-${nowMs}`, + partnerId: careState.partnerId, + kind, + status: "running", + startedAt: nowMs, + endsAt: nowMs + def.durationMs, + }; + return { ok: true, careState: nextState, expedition }; +} + +export function claimExpedition({ expedition, nowMs, random }) { + if (!expedition) { + return { ok: false, reason: "no-expedition" }; + } + if (expedition.status !== "running") { + return { ok: false, reason: "already-claimed" }; + } + if (nowMs < expedition.endsAt) { + return { ok: false, reason: "not-finished" }; + } + const def = EXPEDITIONS[expedition.kind]; + if (!def) { + return { ok: false, reason: "unknown-expedition" }; + } + const roll = typeof random === "function" ? random() : 0; + const index = Math.min( + EXPEDITION_NOTES.length - 1, + Math.max(0, Math.floor(roll * EXPEDITION_NOTES.length)), + ); + return { + ok: true, + reward: { fragments: def.fragments, bond: def.bond, note: EXPEDITION_NOTES[index] }, + }; +} + +const DAILY_GOALS = [ + { id: "care", target: 2 }, + { id: "expedition", target: 1 }, + { id: "feed", target: 10 }, +]; + +export function evaluateDailyGoals({ careEventsToday = 0, fedRequestsToday = 0, expeditionClaimsToday = 0 } = {}) { + const progress = { care: careEventsToday, expedition: expeditionClaimsToday, feed: fedRequestsToday }; + return DAILY_GOALS.map(({ id, target }) => { + const current = Math.max(0, progress[id] ?? 0); + return { id, target, progress: Math.min(current, target), done: current >= target }; + }); +} + +export function evaluateAchievements({ careEventCount = {}, fragments = 0, evolutionCount = 0 } = {}) { + const total = careEventCount.total ?? 0; + const clean = careEventCount.clean ?? 0; + const expedition = careEventCount.expedition ?? 0; + const unlocked = []; + if (total >= 1) unlocked.push("first-care"); + if (clean >= 1) unlocked.push("first-clean"); + if (expedition >= 1) unlocked.push("first-expedition"); + if (total >= 10) unlocked.push("care-10"); + if (fragments >= 100) unlocked.push("fragments-100"); + if (evolutionCount >= 1) unlocked.push("first-evolution"); + return unlocked; +} diff --git a/packages/magpie-digital-pet/lib/pet.care-store.mjs b/packages/magpie-digital-pet/lib/pet.care-store.mjs new file mode 100644 index 00000000..8992be50 --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.care-store.mjs @@ -0,0 +1,536 @@ +// lib/pet.care-store.mjs +// SQLite persistence for the care layer (Task 4). Owns only the new +// magpie_pet_* tables; upstream tables (partners, usage_receipts, ...) are +// read defensively and never written. All gameplay math stays in +// pet.care-rules.mjs — this module only adds transactions and storage. + +import { Database } from "bun:sqlite"; +import { randomUUID } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import { + applyCareAction, + careSummary, + claimExpedition as ruleClaimExpedition, + createDefaultCareState, + decayCareState, + evaluateAchievements, + evaluateDailyGoals, + startExpedition as ruleStartExpedition, +} from "./pet.care-rules.mjs"; + +export const MIGRATION_VERSION = 2; + +// Purely decorative unlockables, paid for with data fragments only. +export const DECORATIONS = { + meadow: { price: 10 }, + night: { price: 25 }, + terminal: { price: 40 }, + ocean: { price: 60 }, + sunset: { price: 80 }, + snow: { price: 120 }, + sakura: { price: 160 }, + void: { price: 200 }, +}; + +// Small fragment reward per completed daily goal, claimable once per day. +export const DAILY_GOAL_REWARDS = { care: 2, expedition: 3, feed: 2 }; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const MIGRATION_SQL = ` +CREATE TABLE IF NOT EXISTS magpie_pet_care_state ( + partner_id TEXT PRIMARY KEY, + satiety REAL NOT NULL, + mood REAL NOT NULL, + energy REAL NOT NULL, + hygiene REAL NOT NULL, + bond REAL NOT NULL, + cooldown_snack INTEGER NOT NULL DEFAULT 0, + cooldown_play INTEGER NOT NULL DEFAULT 0, + cooldown_clean INTEGER NOT NULL DEFAULT 0, + cooldown_rest INTEGER NOT NULL DEFAULT 0, + cooldown_pet_egg INTEGER NOT NULL DEFAULT 0, + last_decay_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS magpie_pet_care_events ( + event_id TEXT PRIMARY KEY, + partner_id TEXT NOT NULL, + kind TEXT NOT NULL, + action TEXT, + message TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS magpie_pet_care_events_partner_created_at + ON magpie_pet_care_events (partner_id, created_at); +CREATE TABLE IF NOT EXISTS magpie_pet_expeditions ( + expedition_id TEXT PRIMARY KEY, + partner_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + claimed_at INTEGER, + fragments INTEGER, + bond INTEGER, + note TEXT +); +CREATE INDEX IF NOT EXISTS magpie_pet_expeditions_partner_status + ON magpie_pet_expeditions (partner_id, status); +CREATE TABLE IF NOT EXISTS magpie_pet_inventory ( + item_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 0, + equipped INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS magpie_pet_achievements ( + achievement_id TEXT PRIMARY KEY, + unlocked_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS magpie_pet_daily ( + date TEXT NOT NULL, + goal_id TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + done INTEGER NOT NULL DEFAULT 0, + reward_claimed INTEGER NOT NULL DEFAULT 0, + claimed_at INTEGER, + PRIMARY KEY (date, goal_id) +); +`; + +const MIGRATION_V2_SQL = ` +CREATE TABLE IF NOT EXISTS magpie_pet_names ( + partner_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + updated_at INTEGER NOT NULL +); +`; + +function localDateString(nowMs) { + const date = new Date(nowMs); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function localDayRangeMs(nowMs) { + const date = new Date(nowMs); + const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + return { startMs, endMs: startMs + DAY_MS }; +} + +function rowToCareState(row) { + return { + partnerId: row.partner_id, + satiety: row.satiety, + mood: row.mood, + energy: row.energy, + hygiene: row.hygiene, + bond: row.bond, + cooldowns: { + snack: row.cooldown_snack, + play: row.cooldown_play, + clean: row.cooldown_clean, + rest: row.cooldown_rest, + petEgg: row.cooldown_pet_egg, + }, + lastDecayAt: row.last_decay_at, + updatedAt: row.updated_at, + }; +} + +function rowToExpedition(row) { + return { + expeditionId: row.expedition_id, + partnerId: row.partner_id, + kind: row.kind, + status: row.status, + startedAt: row.started_at, + endsAt: row.ends_at, + claimedAt: row.claimed_at, + fragments: row.fragments, + bond: row.bond, + note: row.note, + }; +} + +export function createCareStore({ databasePath } = {}) { + if (typeof databasePath !== "string" || databasePath.trim() === "") { + throw new Error("createCareStore requires a databasePath"); + } + mkdirSync(dirname(databasePath.trim()), { recursive: true }); + const db = new Database(databasePath.trim()); + db.run("PRAGMA busy_timeout = 5000"); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); + let closed = false; + + function migrate() { + const version = db.query("PRAGMA user_version").get().user_version; + if (version >= MIGRATION_VERSION) return; + const apply = db.transaction(() => { + if (version < 1) { + db.exec(MIGRATION_SQL); + db.exec("PRAGMA user_version = 1"); + } + if (version < 2) { + db.exec(MIGRATION_V2_SQL); + db.exec(`PRAGMA user_version = ${MIGRATION_VERSION}`); + } + }); + apply(); + } + migrate(); + + function tableExists(name) { + const row = db + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name); + return row !== null && row !== undefined; + } + + function readCareState(partnerId) { + const row = db.query("SELECT * FROM magpie_pet_care_state WHERE partner_id = ?").get(partnerId); + return row ? rowToCareState(row) : null; + } + + function writeCareState(state) { + db.query( + `INSERT INTO magpie_pet_care_state ( + partner_id, satiety, mood, energy, hygiene, bond, + cooldown_snack, cooldown_play, cooldown_clean, cooldown_rest, cooldown_pet_egg, + last_decay_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(partner_id) DO UPDATE SET + satiety = excluded.satiety, mood = excluded.mood, energy = excluded.energy, + hygiene = excluded.hygiene, bond = excluded.bond, + cooldown_snack = excluded.cooldown_snack, cooldown_play = excluded.cooldown_play, + cooldown_clean = excluded.cooldown_clean, cooldown_rest = excluded.cooldown_rest, + cooldown_pet_egg = excluded.cooldown_pet_egg, + last_decay_at = excluded.last_decay_at, updated_at = excluded.updated_at`, + ).run( + state.partnerId, + state.satiety, + state.mood, + state.energy, + state.hygiene, + state.bond, + state.cooldowns.snack, + state.cooldowns.play, + state.cooldowns.clean, + state.cooldowns.rest, + state.cooldowns.petEgg, + state.lastDecayAt, + state.updatedAt, + ); + } + + function insertEvent(partnerId, kind, action, message, nowMs) { + db.query( + "INSERT INTO magpie_pet_care_events (event_id, partner_id, kind, action, message, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run(randomUUID(), partnerId, kind, action, message ?? null, nowMs); + } + + function currentState(partnerId, nowMs) { + const stored = readCareState(partnerId) ?? createDefaultCareState(partnerId, nowMs); + return decayCareState(stored, nowMs); + } + + function runningExpedition(partnerId) { + const row = db + .query("SELECT * FROM magpie_pet_expeditions WHERE partner_id = ? AND status = 'running'") + .get(partnerId); + return row ? rowToExpedition(row) : null; + } + + function getFragments() { + const row = db + .query("SELECT quantity FROM magpie_pet_inventory WHERE item_id = 'fragments'") + .get(); + return row ? row.quantity : 0; + } + + function addFragments(delta) { + db.query( + "INSERT INTO magpie_pet_inventory (item_id, kind, quantity, equipped) VALUES ('fragments', 'currency', 0, 0) ON CONFLICT(item_id) DO NOTHING", + ).run(); + db.query("UPDATE magpie_pet_inventory SET quantity = quantity + ? WHERE item_id = 'fragments'").run(delta); + } + + function countEvolutions() { + if (!tableExists("partners")) return 0; + const row = db + .query("SELECT COUNT(*) AS c FROM partners WHERE current_node_id NOT LIKE '0-%'") + .get(); + return row.c; + } + + function unlockAchievements(nowMs) { + const counts = db + .query( + `SELECT + COALESCE(SUM(CASE WHEN kind = 'care' THEN 1 ELSE 0 END), 0) AS total, + COALESCE(SUM(CASE WHEN kind = 'care' AND action = 'clean' THEN 1 ELSE 0 END), 0) AS clean, + COALESCE(SUM(CASE WHEN kind = 'expedition' THEN 1 ELSE 0 END), 0) AS expedition + FROM magpie_pet_care_events`, + ) + .get(); + const unlocked = evaluateAchievements({ + careEventCount: { total: counts.total, clean: counts.clean, expedition: counts.expedition }, + fragments: getFragments(), + evolutionCount: countEvolutions(), + }); + const newly = []; + for (const id of unlocked) { + const result = db + .query("INSERT OR IGNORE INTO magpie_pet_achievements (achievement_id, unlocked_at) VALUES (?, ?)") + .run(id, nowMs); + if (result.changes > 0) { + newly.push(id); + insertEvent("global", "achievement", id, null, nowMs); + } + } + return newly; + } + + function listAchievements() { + return db + .query("SELECT achievement_id FROM magpie_pet_achievements ORDER BY unlocked_at, achievement_id") + .all() + .map((row) => row.achievement_id); + } + + function readPetName(partnerId) { + const row = db + .query("SELECT name FROM magpie_pet_names WHERE partner_id = ?") + .get(partnerId); + return row ? row.name : null; + } + + function writePetName(partnerId, name, nowMs) { + db.query( + `INSERT INTO magpie_pet_names (partner_id, name, updated_at) VALUES (?, ?, ?) + ON CONFLICT(partner_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`, + ).run(partnerId, name, nowMs); + } + + function countFedRequestsToday(partnerId, startMs, endMs) { + if (!tableExists("usage_receipts")) return 0; + // usage_receipts.created_at is a UTC ISO string; bracket the local day by + // instant so the count matches the local calendar date. + const row = db + .query( + "SELECT COUNT(*) AS c FROM usage_receipts WHERE partner_id = ? AND created_at >= ? AND created_at < ?", + ) + .get(partnerId, new Date(startMs).toISOString(), new Date(endMs).toISOString()); + return row.c; + } + + function computeDailyGoals(partnerId, nowMs) { + const date = localDateString(nowMs); + const { startMs, endMs } = localDayRangeMs(nowMs); + const careEventsToday = db + .query( + "SELECT COUNT(*) AS c FROM magpie_pet_care_events WHERE partner_id = ? AND kind = 'care' AND created_at >= ? AND created_at < ?", + ) + .get(partnerId, startMs, endMs).c; + const expeditionClaimsToday = db + .query( + "SELECT COUNT(*) AS c FROM magpie_pet_care_events WHERE partner_id = ? AND kind = 'expedition' AND created_at >= ? AND created_at < ?", + ) + .get(partnerId, startMs, endMs).c; + const fedRequestsToday = countFedRequestsToday(partnerId, startMs, endMs); + return evaluateDailyGoals({ careEventsToday, fedRequestsToday, expeditionClaimsToday }).map((goal) => { + const stored = db + .query("SELECT reward_claimed FROM magpie_pet_daily WHERE date = ? AND goal_id = ?") + .get(date, goal.id); + return { ...goal, rewardClaimed: stored?.reward_claimed === 1 }; + }); + } + + // Recomputes today's goals and grants each done goal's reward at most once. + function sweepDailyGoals(partnerId, nowMs) { + const date = localDateString(nowMs); + const goals = computeDailyGoals(partnerId, nowMs); + return goals.map((goal) => { + db.query( + `INSERT INTO magpie_pet_daily (date, goal_id, progress, done, reward_claimed, claimed_at) + VALUES (?, ?, ?, ?, 0, NULL) + ON CONFLICT(date, goal_id) DO UPDATE SET progress = excluded.progress, done = excluded.done`, + ).run(date, goal.id, goal.progress, goal.done ? 1 : 0); + let rewardClaimed = goal.rewardClaimed; + if (goal.done && !rewardClaimed) { + const reward = DAILY_GOAL_REWARDS[goal.id] ?? 0; + if (reward > 0) addFragments(reward); + db.query( + "UPDATE magpie_pet_daily SET reward_claimed = 1, claimed_at = ? WHERE date = ? AND goal_id = ?", + ).run(nowMs, date, goal.id); + insertEvent(partnerId, "daily", goal.id, null, nowMs); + rewardClaimed = true; + } + return { ...goal, rewardClaimed }; + }); + } + + function assertReadable() { + if (closed) throw new Error("care store is closed"); + } + + const txPerformAction = db.transaction((partnerId, action, nowMs, context) => { + const state = currentState(partnerId, nowMs); + const result = applyCareAction(state, action, nowMs, context ?? {}); + if (!result.ok) return { ok: false, reason: result.reason, message: result.message }; + writeCareState(result.state); + insertEvent(partnerId, "care", action, result.message, nowMs); + sweepDailyGoals(partnerId, nowMs); + const achievements = unlockAchievements(nowMs); + return { ok: true, message: result.message, state: result.state, achievements }; + }); + + const txStartExpedition = db.transaction((partnerId, kind, nowMs) => { + const state = currentState(partnerId, nowMs); + const result = ruleStartExpedition({ + careState: state, + activeExpedition: runningExpedition(partnerId), + kind, + nowMs, + }); + if (!result.ok) return { ok: false, reason: result.reason }; + writeCareState(result.careState); + const expedition = result.expedition; + db.query( + `INSERT INTO magpie_pet_expeditions (expedition_id, partner_id, kind, status, started_at, ends_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + expedition.expeditionId, + expedition.partnerId, + expedition.kind, + expedition.status, + expedition.startedAt, + expedition.endsAt, + ); + insertEvent(partnerId, "expedition-start", kind, null, nowMs); + return { ok: true, expedition, state: result.careState }; + }); + + const txClaimExpedition = db.transaction((expeditionId, nowMs) => { + const row = db + .query("SELECT * FROM magpie_pet_expeditions WHERE expedition_id = ?") + .get(expeditionId); + const expedition = row ? rowToExpedition(row) : null; + const result = ruleClaimExpedition({ expedition, nowMs, random: Math.random }); + if (!result.ok) return { ok: false, reason: result.reason }; + const reward = result.reward; + db.query( + "UPDATE magpie_pet_expeditions SET status = 'claimed', claimed_at = ?, fragments = ?, bond = ?, note = ? WHERE expedition_id = ?", + ).run(nowMs, reward.fragments, reward.bond, reward.note, expeditionId); + + const state = currentState(expedition.partnerId, nowMs); + const nextState = { ...state, bond: Math.min(100, state.bond + reward.bond), updatedAt: nowMs }; + writeCareState(nextState); + addFragments(reward.fragments); + insertEvent(expedition.partnerId, "expedition", expedition.kind, reward.note, nowMs); + sweepDailyGoals(expedition.partnerId, nowMs); + const achievements = unlockAchievements(nowMs); + return { ok: true, expeditionId, reward, achievements }; + }); + + const txEquipDecoration = db.transaction((decorationId, nowMs) => { + const def = DECORATIONS[decorationId]; + if (!def) return { ok: false, reason: "unknown-decoration" }; + const owned = db + .query("SELECT item_id FROM magpie_pet_inventory WHERE item_id = ? AND kind = 'decoration'") + .get(decorationId); + if (!owned) { + const fragments = getFragments(); + if (fragments < def.price) return { ok: false, reason: "insufficient-fragments" }; + addFragments(-def.price); + db.query( + "INSERT INTO magpie_pet_inventory (item_id, kind, quantity, equipped) VALUES (?, 'decoration', 1, 0)", + ).run(decorationId); + insertEvent("global", "decoration", decorationId, null, nowMs); + } + db.query("UPDATE magpie_pet_inventory SET equipped = 0 WHERE kind = 'decoration'").run(); + db.query("UPDATE magpie_pet_inventory SET equipped = 1 WHERE item_id = ?").run(decorationId); + const achievements = unlockAchievements(nowMs); + return { ok: true, equippedTheme: decorationId, fragments: getFragments(), achievements }; + }); + + return { + getCareSnapshot(partnerId, nowMs) { + assertReadable(); + // Read path only: decay is computed in memory, rewards/achievements are + // granted by the write paths (actions, expedition claims), not by GET. + const state = currentState(partnerId, nowMs); + return { + partnerId, + petName: readPetName(partnerId), + state, + summary: careSummary(state), + activeExpedition: runningExpedition(partnerId), + daily: { date: localDateString(nowMs), goals: computeDailyGoals(partnerId, nowMs) }, + achievements: listAchievements(), + }; + }, + + performAction(partnerId, action, nowMs, context) { + if (closed) return { ok: false, reason: "closed" }; + return txPerformAction(partnerId, action, nowMs, context); + }, + + startExpedition(partnerId, kind, nowMs) { + if (closed) return { ok: false, reason: "closed" }; + return txStartExpedition(partnerId, kind, nowMs); + }, + + claimExpedition(expeditionId, nowMs) { + if (closed) return { ok: false, reason: "closed" }; + return txClaimExpedition(expeditionId, nowMs); + }, + + equipDecoration(decorationId) { + if (closed) return { ok: false, reason: "closed" }; + return txEquipDecoration(decorationId, Date.now()); + }, + + getPetName(partnerId) { + assertReadable(); + return readPetName(partnerId); + }, + + renamePet(partnerId, name, nowMs) { + if (closed) return { ok: false, reason: "closed" }; + const clean = String(name ?? "").trim().replace(/\s+/g, " "); + if (clean.length < 1 || clean.length > 20) return { ok: false, reason: "invalid-name" }; + const run = db.transaction(() => { + writePetName(partnerId, clean, nowMs); + insertEvent(partnerId, "system", "rename", `给它取名为「${clean}」`, nowMs); + }); + run(); + return { ok: true, name: clean }; + }, + + getCollection() { + assertReadable(); + const decorations = db + .query("SELECT item_id, equipped FROM magpie_pet_inventory WHERE kind = 'decoration' ORDER BY item_id") + .all(); + const equipped = decorations.find((row) => row.equipped === 1); + return { + fragments: getFragments(), + unlocked: decorations.map((row) => row.item_id), + equippedTheme: equipped ? equipped.item_id : null, + achievements: listAchievements(), + }; + }, + + close() { + if (closed) return; + closed = true; + db.close(); + }, + }; +} diff --git a/packages/magpie-digital-pet/lib/pet.frames.mjs b/packages/magpie-digital-pet/lib/pet.frames.mjs new file mode 100644 index 00000000..ac50ddb7 --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.frames.mjs @@ -0,0 +1,40 @@ +// lib/pet.frames.mjs +// +// This package ships no sprite art. The page draws a deterministic procedural +// pixel blob per pet instead (see page/app.js drawFallback), so the frame +// catalog is intentionally empty. The helpers stay so the server can expose a +// stable `frames: {}` shape without reading any bundled asset. + +/** + * @returns {Map>} + */ +export function loadMonsterFrames() { + return new Map(); +} + +/** + * @param {Map>} frames + * @param {string} sprite + * @returns {Record | undefined} + */ +export function getSpriteFrames(frames, sprite) { + return frames.get(sprite); +} + +/** + * @param {Map>} frames + * @param {string} sprite + * @param {string[]} preferredNames + * @returns {{ name: string, content: string } | undefined} + */ +export function chooseFrame(frames, sprite, preferredNames) { + const spriteFrames = frames.get(sprite); + if (!spriteFrames) return undefined; + for (const name of preferredNames) { + const content = spriteFrames[name]; + if (typeof content === "string" && content.length > 0) { + return { name, content }; + } + } + return undefined; +} diff --git a/packages/magpie-digital-pet/lib/pet.server.mjs b/packages/magpie-digital-pet/lib/pet.server.mjs new file mode 100644 index 00000000..3d46ae3c --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.server.mjs @@ -0,0 +1,562 @@ +// magpie-digital-pet · sidebar page server +// +// The page-chat pattern: magpie's plugin pages run in a sandboxed iframe with +// CSP connect-src 'none' — the only networking they may do is navigating +// themselves. So the sidebar "page" is a guide page (page/index.html + boot.js) +// that jumps to this local HTTP server, which serves the real dashboard and +// a small JSON API over pet.db. +// +// Binds 127.0.0.1 only. Fixed default port (3571) so the guide page can jump +// blindly — several host processes may load the plugin; the first one serves, +// the others see EADDRINUSE and quietly step aside (their watchers still run). + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Database } from "bun:sqlite"; +import { createPetAdapter, nodeById, DIGIMON_DATA } from "./pet.adapter.mjs"; +import { createCareStore } from "./pet.care-store.mjs"; +import { EXPEDITIONS } from "./pet.care-rules.mjs"; +import { DECORATIONS } from "./pet.care-store.mjs"; +import { chooseFrame, getSpriteFrames, loadMonsterFrames } from "./pet.frames.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); // .../lib +const UI_ROOT = join(dirname(HERE), "page"); + +const DEFAULT_PORT = 3571; + +// engine stage thresholds, mirrored for display only (upstream defaults) +const STAGE_THRESHOLDS = [5e6, 1e7, 2e7, 4e7, 75e6, 125e6, 2e8, 3e8]; +const STAGE_NAMES = ["egg", "baby I", "baby II", "child", "adult", "perfect", "ultimate", "super ultimate"]; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json; charset=utf-8", +}; + +function fmtTokens(n) { + if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return String(n); +} + +function openReadOnly(dbPath) { + const db = new Database(dbPath, { readonly: true }); + db.run("PRAGMA busy_timeout = 5000"); + return db; +} + +function nodeInfo(id) { + const node = nodeById(id); + if (!node) return { id, nameEn: id, nameJp: id, stage: 0, sprite: "egg", url: "" }; + return { + id: node.id, + nameEn: node.name_en, + nameJp: node.name_jp, + stage: node.stage, + sprite: node.sprite, + url: node.url, + }; +} + +// the whole dashboard in one object +function readState(dbPath) { + try { + const db = openReadOnly(dbPath); + try { + const trainer = db.query("SELECT total_tokens FROM trainer_state WHERE trainer_id = 1").get(); + const control = db.query("SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1").get(); + const partner = db + .query( + `SELECT partner_id, generation, current_node_id, gauge, is_terminal, + pending_evolution_target_id, battle_opponent_node_id, created_at + FROM partners + WHERE partner_id = (SELECT principal_id FROM partner_team WHERE team_id = 1) + AND retired_at IS NULL LIMIT 1`, + ) + .get(); + const receipts = db.query("SELECT COUNT(*) c, SUM(token_delta) t, SUM(cost) s FROM usage_receipts").get(); + const events = db + .query( + `SELECT kind, current_node_id, gauge, token_delta, created_at + FROM partner_events ORDER BY created_at DESC, event_id DESC LIMIT 12`, + ) + .all(); + const partners = db + .query( + `SELECT generation, current_node_id, retired_at, created_at + FROM partners ORDER BY generation ASC`, + ) + .all(); + + const engineNode = partner ? nodeInfo(partner.current_node_id) : null; + const node = control?.cheat_node_id ? nodeInfo(control.cheat_node_id) : engineNode; + const threshold = node ? STAGE_THRESHOLDS[node.stage] ?? 0 : 0; + return { + ok: true, + trainerTokens: trainer?.total_tokens ?? 0, + fedRequests: receipts?.c ?? 0, + fedTokens: receipts?.t ?? 0, + fedCost: Number.isFinite(Number(receipts?.s)) ? Number(receipts.s) : 0, + frozen: control?.frozen === 1, + pinned: control?.cheat_node_id ?? null, + partner: partner + ? { + id: partner.partner_id, + generation: partner.generation, + node, + engineNode, + stageName: STAGE_NAMES[node?.stage] ?? "?", + gauge: partner.gauge, + threshold, + pct: threshold > 0 ? Math.min(100, (partner.gauge / threshold) * 100) : 0, + isTerminal: partner.is_terminal === 1, + battle: partner.battle_opponent_node_id + ? nodeInfo(partner.battle_opponent_node_id) + : null, + createdAt: partner.created_at, + } + : null, + events: events.map((e) => ({ + kind: e.kind, + node: nodeInfo(e.current_node_id), + gauge: e.gauge, + tokenDelta: e.token_delta, + createdAt: e.created_at, + })), + partners: partners.map((p) => ({ + generation: p.generation, + node: nodeInfo(p.current_node_id), + retired: p.retired_at !== null, + createdAt: p.created_at, + })), + fmt: { trainer: fmtTokens(trainer?.total_tokens ?? 0), fed: fmtTokens(receipts?.t ?? 0) }, + }; + } finally { + db.close(); + } + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } +} + +const COMMANDS = new Set(["digital-pet-spawn", "digital-pet-freeze", "digital-pet-unfreeze", "digital-pet-set"]); + +// page care whitelist: page command -> care action. petEgg additionally +// carries the current stage so the egg-only rule is enforced server-side. +const CARE_COMMANDS = new Map([ + ["pet-feed-snack", "snack"], + ["pet-play", "play"], + ["pet-clean", "clean"], + ["pet-rest", "rest"], + ["pet-pet-egg", "petEgg"], +]); +const EXPEDITION_KINDS = new Set(Object.keys(EXPEDITIONS)); +const DECORATION_IDS = new Set(Object.keys(DECORATIONS)); +const API_TOKEN = randomBytes(32).toString("hex"); +const TOKEN_HEADER = "x-magpie-pet-token"; + +// First-frame preference for the dashboard sprite, from the live care state. +// The page can still switch to any of the other frames shipped in `frames`. +function framePreference(careState) { + if (!careState) return ["walk_1", "walk_2", "happy"]; + if (careState.energy < 25) return ["sleep_1", "sleep_2", "walk_1", "walk_2"]; + if (careState.satiety < 25) return ["angry", "eat_1", "eat_2", "walk_1", "walk_2"]; + if (careState.hygiene < 25) return ["injured_1", "injured_2", "walk_1", "walk_2"]; + if (careState.mood >= 75) return ["happy", "walk_1", "walk_2"]; + return ["walk_1", "walk_2", "happy"]; +} + +export function createPetServer({ databasePath, dbPath, adapter: sharedAdapter, port = DEFAULT_PORT, notifyGate, retryMs = 30_000 } = {}) { + const adapter = sharedAdapter ?? createPetAdapter({ databasePath, dbPath, notifyGate }); + const dbFile = adapter.databasePath; + const careStore = createCareStore({ databasePath: dbFile }); + const frames = loadMonsterFrames(); + + // upstream snapshot merged with the care layer; never throws past readState + function readFullState() { + const state = readState(dbFile); + const partnerId = state.ok && state.partner ? state.partner.id : null; + const nowMs = Date.now(); + let care = null; + let expedition = null; + let daily = null; + if (partnerId) { + const snapshot = careStore.getCareSnapshot(partnerId, nowMs); + care = snapshot; + expedition = snapshot.activeExpedition; + daily = snapshot.daily; + } + let collection = null; + try { + collection = careStore.getCollection(); + } catch { + // store already closed; leave collection null + } + let frame = null; + let allFrames = {}; + const sprite = state.ok && state.partner ? state.partner.node?.sprite : null; + const spriteFrames = sprite ? getSpriteFrames(frames, sprite) : undefined; + if (spriteFrames) { + allFrames = spriteFrames; + const picked = chooseFrame(frames, sprite, framePreference(care?.state)); + if (picked) frame = { ...picked, available: Object.keys(spriteFrames) }; + } + const unlockedIds = new Set(); + if (state.ok) { + for (const p of state.partners || []) if (p.node?.id) unlockedIds.add(p.node.id); + if (state.partner?.node?.id) unlockedIds.add(state.partner.node.id); + } + const dex = DIGIMON_DATA.map((d) => ({ + id: d.id, + nameEn: d.name_en, + nameJp: d.name_jp, + stage: d.stage, + sprite: d.sprite, + unlocked: unlockedIds.has(d.id), + current: state.ok && state.partner?.node?.id === d.id, + })); + return { ...state, care, expedition, collection, daily, frame, frames: allFrames, petName: care?.petName ?? null, dex }; + } + + function sendCareResult(res, result, achievements) { + return send(res, 200, { + text: result.message ?? "", + toasts: (achievements ?? result.achievements ?? []).map((id) => ({ title: "成就解锁", message: id })), + state: readFullState(), + }); + } + + async function readBody(req) { + const chunks = []; + let size = 0; + for await (const c of req) { + size += c.length; + if (size > 64 * 1024) throw new Error("body too large"); + chunks.push(c); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + return {}; + } + } + + // The magpie page frame is sandboxed with an opaque origin, so Origin: null + // alone is not proof of anything. Every /api/* route except /api/ping needs + // the per-process token that only the served HTML gets; Host must be this + // server's own loopback name, and CORS is never wildcarded. + function hostAllowed(req) { + const actual = server.address()?.port; + if (!actual) return false; + const host = String(req.headers.host || "").toLowerCase(); + return host === `127.0.0.1:${actual}` || host === `localhost:${actual}`; + } + + function ownOrigins() { + const actual = server.address()?.port; + if (!actual) return new Set(); + return new Set([`http://127.0.0.1:${actual}`, `http://localhost:${actual}`]); + } + + function originAllowed(req) { + const origin = req.headers.origin; + return origin === undefined || origin === "null" || ownOrigins().has(origin); + } + + function tokenOK(req) { + const got = Buffer.from(String(req.headers[TOKEN_HEADER] || "")); + const want = Buffer.from(API_TOKEN); + return got.length === want.length && timingSafeEqual(got, want); + } + + function corsHeaders(req) { + const origin = req.headers.origin; + const allow = origin === "null" ? "null" : origin && ownOrigins().has(origin) ? origin : "null"; + return { + "access-control-allow-origin": allow, + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": `content-type, ${TOKEN_HEADER}`, + vary: "Origin", + }; + } + + function send(res, status, body, type = "application/json; charset=utf-8", cors = true) { + const data = typeof body === "string" || body instanceof Uint8Array ? body : JSON.stringify(body); + res.writeHead(status, { + ...(cors ? corsHeaders(res.req) : {}), + "content-type": type, + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + res.end(data); + } + + function sendPreflight(res) { + res.writeHead(204, { + ...corsHeaders(res.req), + "access-control-max-age": "600", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + res.end(); + } + + const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); + const path = url.pathname; + if (!hostAllowed(req)) return send(res, 403, { error: "只服务本机宠物页" }, undefined, false); + if (!originAllowed(req)) return send(res, 403, { error: "只服务本机宠物页" }); + if (req.method === "OPTIONS") return sendPreflight(res); + if (path.startsWith("/api/") && path !== "/api/ping" && !tokenOK(req)) { + return send(res, 403, { error: "口令不对:请重新打开宠物页", reload: true }); + } + try { + if (path === "/api/ping" && req.method === "GET") { + return send(res, 200, { ok: true }); + } + if (path === "/api/state" && req.method === "GET") { + return send(res, 200, readFullState()); + } + if (path === "/api/command" && req.method === "POST") { + const body = await readBody(req); + const name = String(body.name || ""); + const args = body.args === undefined ? undefined : String(body.args).slice(0, 64); + + // --- care layer commands (Task 5); everything validated server-side + if ( + CARE_COMMANDS.has(name) || + name === "pet-expedition-start" || + name === "pet-expedition-claim" || + name === "pet-decoration-equip" || + name === "pet-rename" + ) { + const nowMs = Date.now(); + const current = readState(dbFile); + const partnerId = current.ok && current.partner ? current.partner.id : null; + + if (CARE_COMMANDS.has(name)) { + if (!partnerId) return send(res, 400, { error: "没有活跃的伙伴可以照顾。", reason: "no-active-partner" }); + const action = CARE_COMMANDS.get(name); + const context = action === "petEgg" ? { stage: current.partner.engineNode?.stage ?? current.partner.node?.stage } : {}; + const result = careStore.performAction(partnerId, action, nowMs, context); + if (!result.ok) return send(res, 400, { error: result.message ?? "操作失败。", reason: result.reason }); + return sendCareResult(res, result); + } + + if (name === "pet-expedition-start") { + const kind = String(args ?? ""); + if (!EXPEDITION_KINDS.has(kind)) { + return send(res, 400, { error: `unknown expedition kind: ${kind}`, reason: "unknown-expedition" }); + } + if (!partnerId) return send(res, 400, { error: "没有活跃的伙伴可以探险。", reason: "no-active-partner" }); + const result = careStore.startExpedition(partnerId, kind, nowMs); + if (!result.ok) return send(res, 400, { error: `无法开始探险:${result.reason}`, reason: result.reason }); + return send(res, 200, { + text: `出发去${kind}探险了!`, + toasts: [], + state: readFullState(), + }); + } + + if (name === "pet-expedition-claim") { + const expeditionId = String(args ?? ""); + if (!expeditionId) { + return send(res, 400, { error: "claim 需要 expeditionId", reason: "no-expedition" }); + } + const result = careStore.claimExpedition(expeditionId, nowMs); + if (!result.ok) return send(res, 400, { error: `无法结算探险:${result.reason}`, reason: result.reason }); + return sendCareResult(res, { ...result, message: result.reward.note }); + } + + if (name === "pet-rename") { + if (!partnerId) return send(res, 400, { error: "没有活跃的伙伴可以命名。", reason: "no-active-partner" }); + const result = careStore.renamePet(partnerId, args, nowMs); + if (!result.ok) return send(res, 400, { error: "名字需要 1–20 个字符。", reason: result.reason }); + return send(res, 200, { text: `给它取名为「${result.name}」`, toasts: [], state: readFullState() }); + } + + // pet-decoration-equip + const decorationId = String(args ?? ""); + if (!DECORATION_IDS.has(decorationId)) { + return send(res, 400, { error: `unknown decoration: ${decorationId}`, reason: "unknown-decoration" }); + } + const result = careStore.equipDecoration(decorationId); + if (!result.ok) return send(res, 400, { error: `无法装备装饰:${result.reason}`, reason: result.reason }); + return send(res, 200, { + text: `已换上「${decorationId}」主题。`, + toasts: (result.achievements ?? []).map((id) => ({ title: "成就解锁", message: id })), + state: readFullState(), + }); + } + + // --- local guards and pin management + if (name === "pet-unpin") { + try { + const rw = new Database(dbFile); + rw.run("PRAGMA busy_timeout = 5000"); + rw.run("UPDATE vpet_control_state SET cheat_node_id = NULL WHERE control_id = 1"); + rw.close(); + return send(res, 200, { text: "已取消钉住,恢复自然生长。", toasts: [], state: readFullState() }); + } catch (e) { + return send(res, 400, { error: `无法取消钉住:${e?.message ?? e}`, reason: "unpin-failed" }); + } + } + if (name === "digital-pet-spawn") { + const current = readState(dbFile); + const stage = current.ok && current.partner + ? current.partner.engineNode?.stage ?? current.partner.node?.stage ?? 0 + : null; + if (stage !== null && stage < 2) { + return send(res, 400, { error: "当前伙伴达到 baby II 后才能孵下一颗蛋。", reason: "stage-required" }); + } + const createdAt = current.ok && current.partner ? Date.parse(current.partner.createdAt) : NaN; + if (Number.isFinite(createdAt) && Date.now() - createdAt < 60_000) { + const waitSec = Math.ceil((60_000 - (Date.now() - createdAt)) / 1000); + return send(res, 400, { error: `新蛋刚孵化,${waitSec} 秒后才能再次孵蛋。`, reason: "spawn-cooldown" }); + } + } + + // --- original engine commands, unchanged + if (!COMMANDS.has(name)) return send(res, 400, { error: `unknown command: ${name}`, reason: "unknown-command" }); + if (name === "digital-pet-set" && !/^[0-9]-[0-9]{1,3}$/.test(String(args ?? ""))) { + return send(res, 400, { error: "set 需要一个 Digimon ID,例如 4-001", reason: "invalid-args" }); + } + const result = await adapter.runCommand(name, args); + return send(res, 200, { + text: result.text, + toasts: result.toasts.map((t) => ({ title: t?.title, message: t?.message })), + state: readFullState(), + }); + } + + // static: the dashboard itself (page/app.html and friends). Resolve the + // decoded URL against UI_ROOT and only serve files strictly inside it. + let rel; + try { + rel = decodeURIComponent(path).replace(/^\/+/, ""); + } catch { + return send(res, 400, { error: "bad path", reason: "bad-path" }); + } + const file = resolve(UI_ROOT, rel === "" ? "app.html" : rel); + const insideRoot = relative(UI_ROOT, file); + if (insideRoot.startsWith("..") || isAbsolute(insideRoot)) { + return send(res, 403, { error: "forbidden" }); + } + let data = await readFile(file).catch(() => null); + if (!data) return send(res, 404, "not found", "text/plain; charset=utf-8"); + if (file.endsWith("app.html")) { + const html = data.toString("utf8"); + const tokenScript = ``; + const stamped = html.replace('src="app.js"', `src="app.js?v=${API_TOKEN.slice(0, 8)}"`); + data = Buffer.from(stamped.replace("", `${tokenScript}`), "utf8"); + } + return send(res, 200, data, MIME[extname(file).toLowerCase()] || "application/octet-stream"); + } catch (e) { + return send(res, 500, { error: String(e?.message ?? e) }); + } + }); + + // track live sockets so close() can drop keep-alive connections instead of + // waiting for the client to let go + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + + const bind = (p) => + new Promise((resolve, reject) => { + const onErr = (err) => { + server.removeListener("listening", onOk); + reject(err); + }; + const onOk = () => { + server.removeListener("error", onErr); + resolve(); + }; + server.once("error", onErr); + server.once("listening", onOk); + server.listen(p, "127.0.0.1"); + }); + + let retryTimer = null; + let serving = false; + let boundPort = port; + + async function tryBindOnce() { + try { + await bind(port); + boundPort = server.address()?.port ?? port; + serving = true; + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } + console.log(`[magpie-digital-pet] sidebar page at http://127.0.0.1:${boundPort}/ · db ${dbFile}`); + return true; + } catch (e) { + if (e?.code !== "EADDRINUSE") { + console.log("[magpie-digital-pet] server bind failed:", e?.message ?? e); + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } + return false; + } + // another host process holds the port right now. Hosts come and go + // (a CLI `plugin add` host lives seconds); keep retrying quietly and + // take the port over when it frees up. + return false; + } + } + + async function start() { + const bound = await tryBindOnce(); + if (bound) return true; + if (!retryTimer) { + console.log(`[magpie-digital-pet] port ${port} busy; will retry until it frees`); + retryTimer = setInterval( + () => { + if (serving) return; + tryBindOnce().catch(() => {}); + }, + Math.max(200, retryMs), + ); + if (typeof retryTimer?.unref === "function") retryTimer.unref(); + } + return false; + } + + let closed = false; + + return { + get port() { + return boundPort; + }, + databasePath: dbFile, + adapter, + apiToken: API_TOKEN, + start, + close: async () => { + if (closed) return; + closed = true; + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + careStore.close(); + }, + }; +} + +export const PET_PAGE_PORT = DEFAULT_PORT; diff --git a/packages/magpie-digital-pet/lib/pet.watcher.mjs b/packages/magpie-digital-pet/lib/pet.watcher.mjs new file mode 100644 index 00000000..294c3b66 --- /dev/null +++ b/packages/magpie-digital-pet/lib/pet.watcher.mjs @@ -0,0 +1,192 @@ +// magpie-digital-pet · usage watcher +// +// Polls magpie's usage API and feeds every completed request into the pet +// engine through the adapter. Rows carry exactly the fields we need +// (route_id for idempotency, in/out/cache_read/cache_write/reasoning/cost), +// which is why this design beats parsing response bodies: magpie has already +// accounted every request, streams included. +// +// Concurrency: several host processes may each run a watcher on the same +// database. That is safe — the engine dedupes by receipt key (route_id) and +// SQLite runs in WAL with a busy timeout — and the state file is +// last-writer-wins with a deliberate overlap handled by the same receipts. + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createPetAdapter } from "./pet.adapter.mjs"; + +const DEFAULT_GATEWAY = "http://127.0.0.1:3425"; + +function stateFileFor(dbPath) { + return join(dirname(dbPath), "watcher-state.json"); +} + +function loadState(file, backfill) { + try { + if (existsSync(file)) { + const parsed = JSON.parse(readFileSync(file, "utf8")); + if (Number.isFinite(parsed?.since)) return { since: parsed.since }; + } + } catch {} + // first run: count only new usage (no backfill) unless asked otherwise + return { since: backfill ? 0 : Date.now() - 5 * 60 * 1000 }; +} + +export function createPetWatcher({ + databasePath, + dbPath, + adapter: sharedAdapter, + gatewayUrl = DEFAULT_GATEWAY, + pollSeconds = 60, + includeCacheTokens = false, + notifications = true, + backfill = false, + fetchImpl, +} = {}) { + const fetchFn = fetchImpl ?? (typeof fetch === "function" ? fetch : null); + if (!fetchFn) throw new Error("no fetch available in this runtime"); + + const adapter = sharedAdapter ?? createPetAdapter({ + databasePath, + dbPath, + notifyGate: () => notifications !== false, + }); + const stateFile = stateFileFor(adapter.databasePath); + + let timer = null; + let running = false; + let polling = false; + let since = loadState(stateFile, backfill).since; + let lastError = null; + let fedTotal = 0; + + const intervalMs = Math.max(15, Math.min(Number(pollSeconds) || 60, 3600)) * 1000; + + function persistState() { + try { + mkdirSync(dirname(stateFile), { recursive: true }); + writeFileSync(stateFile, JSON.stringify({ since, savedAt: new Date().toISOString() })); + } catch {} + } + + const rowKey = (row) => { + if (typeof row.route_id === "number" || (typeof row.route_id === "string" && row.route_id)) { + return `route:${row.route_id}`; + } + return `row:${row.t}|${row.session ?? ""}|${row.agent ?? ""}|${row.model ?? ""}|${row.in ?? 0}|${row.out ?? 0}`; + }; + + const rowTime = (row) => { + const ms = Date.parse(row.t); + return Number.isFinite(ms) ? ms : 0; + }; + + async function pollOnce() { + const url = `${gatewayUrl.replace(/\/+$/, "")}/v1/magpie/usage/requests`; + const res = await fetchFn(url); + if (!res || typeof res.json !== "function") throw new Error("usage API: no json"); + if (res.status && res.status !== 200) throw new Error(`usage API: HTTP ${res.status}`); + const data = await res.json(); + const rows = Array.isArray(data?.rows) ? data.rows : []; + if (rows.length === 0) return 0; + + // chronological order keeps the gauge progression sane + const fresh = rows + .map((row) => ({ row, key: rowKey(row), tMs: rowTime(row) })) + .filter((x) => x.tMs > 0 && x.tMs >= since) + .sort((a, b) => a.tMs - b.tMs); + + let fed = 0; + let maxT = since; + for (const { row, key, tMs } of fresh) { + if (tMs > maxT) maxT = tMs; + const status = Number(row.status); + if (Number.isFinite(status) && status >= 400) continue; // failed requests don't feed + const input = Number(row.in) || 0; + const output = Number(row.out) || 0; + const reasoning = Number(row.reasoning) || 0; + const cacheRead = includeCacheTokens ? Number(row.cache_read) || 0 : 0; + const cacheWrite = includeCacheTokens ? Number(row.cache_write) || 0 : 0; + if (input + output + reasoning + cacheRead + cacheWrite <= 0) continue; + + const result = await adapter.feedUsage({ + id: key, + tokens: { input, output, reasoning, cacheRead, cacheWrite }, + cost: Number(row.cost) > 0 ? Number(row.cost) : 0, + completedAt: tMs, + }); + if (result?.kind === "fed") fed++; + // duplicates (another watcher process, or the since-overlap) are fine: + // the engine's receipt table already has the key + } + // keep a small overlap so late-arriving rows with older timestamps are + // still picked up; receipts absorb the re-feeds + since = Math.max(since, maxT - 5 * 60 * 1000); + persistState(); + return fed; + } + + async function tick() { + if (polling) return; + polling = true; + try { + const fed = await pollOnce(); + fedTotal += fed; + lastError = null; + if (fed > 0) { + console.log(`[magpie-digital-pet] fed ${fed} request${fed === 1 ? "" : "s"} (${fedTotal} total)`); + } + } catch (e) { + if (String(e?.message) !== String(lastError)) { + lastError = e?.message ?? String(e); + console.log("[magpie-digital-pet] poll failed:", lastError); + } + } finally { + polling = false; + } + } + + return { + databasePath: adapter.databasePath, + stateFile, + get since() { + return since; + }, + stats() { + return { fedTotal, lastError, intervalMs }; + }, + start() { + if (running) return; + running = true; + setTimeout(async () => { + try { + // a fresh install has no partner: hatch one so usage counts from + // the first poll (usage before a partner exists is not recorded) + if (!adapter.hasActivePartner()) { + const spawn = await adapter.runCommand("digital-pet-spawn"); + console.log("[magpie-digital-pet] auto-spawned a partner:", spawn.text || "generation 1"); + } + } catch {} + await tick().catch(() => {}); + if (timer) clearInterval(timer); + timer = setInterval(() => { + tick().catch(() => {}); + }, intervalMs); + if (typeof timer?.unref === "function") timer.unref(); + }, 3000); + if (typeof timer?.unref === "function") timer.unref(); + console.log( + `[magpie-digital-pet] watching ${gatewayUrl} every ${Math.round(intervalMs / 1000)}s · db ${adapter.databasePath}`, + ); + }, + async stop() { + running = false; + if (timer) { + clearInterval(timer); + timer = null; + } + persistState(); + }, + pollOnce, + }; +} diff --git a/packages/magpie-digital-pet/package.json b/packages/magpie-digital-pet/package.json new file mode 100644 index 00000000..fed91b3f --- /dev/null +++ b/packages/magpie-digital-pet/package.json @@ -0,0 +1,76 @@ +{ + "name": "@magpie-community/page-digital-pet", + "version": "0.6.0", + "description": "A virtual pet page for the magpie gateway: routed usage feeds the active partner, with local care, expeditions and collections. Depends on @jcendal/opencode-digital-pet 0.18.0; this package bundles no sprite art. Polls magpie's own usage API; never touches agents' contexts.", + "type": "module", + "main": "./index.mjs", + "files": [ + "index.mjs", + "lib", + "cli", + "page", + "ui", + "README.md", + "LICENSE", + "NOTICE" + ], + "keywords": [ + "magpie", + "magpie-plugin", + "magpie-page", + "opencode", + "opencode-plugin", + "virtual-pet" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/magpie-community/plugins.git", + "directory": "packages/magpie-digital-pet" + }, + "bugs": { + "url": "https://github.com/magpie-community/plugins/issues" + }, + "homepage": "https://github.com/manjughosa/magpie-digital-pet#readme", + "engines": { + "magpie": ">=0.1.1340" + }, + "dependencies": { + "@jcendal/opencode-digital-pet": "0.18.0" + }, + "magpie": { + "icon": "page/icon.svg", + "page": { + "title": "Digital Pet", + "file": "page/index.html", + "icon": "page/icon.svg", + "service": { + "port": 3571, + "option": "port" + }, + "titles": { + "zh": "宠物", + "zh-TW": "寵物", + "ja": "デジペット" + } + }, + "panels": [ + { + "slot": "settings", + "title": "Digital Pet", + "file": "ui/panel.html" + } + ], + "options": { + "enabled": true, + "notifications": true, + "includeCacheTokens": false, + "backfill": false, + "pollSeconds": 60, + "gatewayUrl": "http://127.0.0.1:3425", + "dbPath": "", + "port": 3571 + }, + "permissions": [] + } +} diff --git a/packages/magpie-digital-pet/page/app.css b/packages/magpie-digital-pet/page/app.css new file mode 100644 index 00000000..d137d500 --- /dev/null +++ b/packages/magpie-digital-pet/page/app.css @@ -0,0 +1,526 @@ +/* app.css — 宠物主页视觉:浅色/深色主题、栖息地装饰变体、低帧率像素场景。 */ + +:root { + --accent: #c9822a; + --accent-fg: #c9822a; + --ok: #15803d; + --warn: #b45309; + --bg: #f4f1ea; + --fg: #29241c; + --muted: #837a6b; + --line: rgba(70, 58, 34, 0.16); + --card: #fffdf7; + --card-2: #faf6ec; + --shadow: 0 10px 30px rgba(90, 70, 30, 0.12); + --bar-track: rgba(70, 58, 34, 0.12); + --sky-top: #cfe4f7; + --sky-bottom: #f6ead3; + color-scheme: light; +} + +html.theme-dark { + --accent: #e8a33d; + --accent-fg: #f0b95a; + --ok: #4ade80; + --warn: #f0b95a; + --bg: #14120e; + --fg: #ece5d8; + --muted: #9b9184; + --line: rgba(236, 229, 216, 0.16); + --card: #1e1b15; + --card-2: #242018; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + --bar-track: rgba(236, 229, 216, 0.14); + --sky-top: #1d2733; + --sky-bottom: #171310; + color-scheme: dark; +} + +html.theme-light { + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not(.theme-light):not(.theme-dark) { + --accent: #e8a33d; + --accent-fg: #f0b95a; + --ok: #4ade80; + --warn: #f0b95a; + --bg: #14120e; + --fg: #ece5d8; + --muted: #9b9184; + --line: rgba(236, 229, 216, 0.16); + --card: #1e1b15; + --card-2: #242018; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + --bar-track: rgba(236, 229, 216, 0.14); + --sky-top: #1d2733; + --sky-bottom: #171310; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 18px 20px 40px; + font: 13px/1.6 var(--magpie-font, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif); + background: var(--bg); + color: var(--fg); +} + +main { + max-width: 1080px; + margin: 0 auto; +} + +.hud-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.85fr); + gap: 12px; + align-items: start; + margin-top: 12px; +} + +.hud-main, +.hud-side { + display: grid; + gap: 12px; + align-content: start; +} + +.archive { margin-top: 12px; } + +.muted { color: var(--muted); } +.small { font-size: 12px; } + +.topbar { + max-width: 1080px; + margin: 0 auto 12px; + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} +h1 { font-size: 16px; margin: 0; } +.topbar p { margin: 0; } +h2 { + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + margin: 0 0 10px; + color: var(--fg); + font-weight: 650; + letter-spacing: 0.01em; +} +h2::before { + content: ""; + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--accent); +} + +section { + min-width: 0; + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px 16px; + box-shadow: 0 4px 18px rgba(90, 70, 30, 0.08); +} + +/* ---------------- 栖息地 hero ---------------- */ + +.habitat { + padding: 0; + overflow: hidden; + border-width: 2px; +} + +.habitat .sky { + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; + height: clamp(220px, 30vw, 330px); + background: linear-gradient(180deg, var(--sky-top), var(--sky-bottom)); + border-bottom: 1px solid var(--line); +} + +#pet-canvas { + display: block; + width: auto; + max-width: 100%; + height: 100%; + aspect-ratio: 288 / 176; + image-rendering: pixelated; +} + +.habitat-info { padding: 14px 18px 16px; } + +/* 装饰主题变体:只改变栖息地天空风格 */ +.habitat--meadow .sky { + background: linear-gradient(180deg, #bfe3b4 0%, #dff0c4 55%, #9cc184 100%); +} +html.theme-dark .habitat--meadow .sky { + background: linear-gradient(180deg, #16301e 0%, #1d3a22 55%, #101c12 100%); +} +.habitat--night .sky { + background: + radial-gradient(2px 2px at 18% 24%, rgba(255, 255, 255, 0.9), transparent 60%), + radial-gradient(1.5px 1.5px at 42% 12%, rgba(255, 255, 255, 0.7), transparent 60%), + radial-gradient(2px 2px at 66% 30%, rgba(255, 255, 255, 0.8), transparent 60%), + radial-gradient(1.5px 1.5px at 82% 16%, rgba(255, 255, 255, 0.6), transparent 60%), + radial-gradient(1.5px 1.5px at 64% 8%, rgba(255, 255, 255, 0.5), transparent 60%), + linear-gradient(180deg, #101a33 0%, #0a1024 60%, #05070f 100%); +} +.habitat--terminal .sky { + background: linear-gradient(180deg, #02120a 0%, #01100a 60%, #000 100%); +} +.habitat--terminal .sky::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.22) 0 1px, transparent 1px 3px); +} +.habitat--ocean .sky { + background: linear-gradient(180deg, #bfe7ff 0%, #8fc7ea 55%, #5d9fbe 100%); +} +html.theme-dark .habitat--ocean .sky { + background: linear-gradient(180deg, #0d2f45 0%, #0a2437 60%, #071927 100%); +} +.habitat--sunset .sky { + background: linear-gradient(180deg, #ffd7a8 0%, #f2a07b 55%, #9e5a78 100%); +} +html.theme-dark .habitat--sunset .sky { + background: linear-gradient(180deg, #3a2035 0%, #2a1a2e 60%, #17101d 100%); +} +.habitat--snow .sky { + background: linear-gradient(180deg, #f4fbff 0%, #dbeef7 60%, #b9d4e4 100%); +} +html.theme-dark .habitat--snow .sky { + background: linear-gradient(180deg, #263442 0%, #1d2834 60%, #121a24 100%); +} +.habitat--sakura .sky { + background: + radial-gradient(10px 10px at 18% 24%, rgba(255, 255, 255, 0.55), transparent 65%), + radial-gradient(8px 8px at 72% 18%, rgba(255, 255, 255, 0.45), transparent 65%), + linear-gradient(180deg, #ffd9e6 0%, #f5b7cd 55%, #c78ca9 100%); +} +html.theme-dark .habitat--sakura .sky { + background: linear-gradient(180deg, #38202c 0%, #2b1a25 60%, #181018 100%); +} +.habitat--void .sky { + background: + radial-gradient(2px 2px at 22% 20%, rgba(180, 140, 255, 0.9), transparent 65%), + radial-gradient(2px 2px at 68% 34%, rgba(120, 220, 255, 0.75), transparent 65%), + radial-gradient(1px 1px at 82% 12%, rgba(255, 255, 255, 0.75), transparent 65%), + linear-gradient(180deg, #120d24 0%, #070612 100%); +} + +.pet-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } +.pet-name { font-size: 20px; font-weight: 700; letter-spacing: 0.01em; } +.pet-jp { color: var(--muted); font-size: 13px; } +.badge { + font-size: 11px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + white-space: nowrap; +} +.badge.warn { color: var(--warn); border-color: var(--warn); } + +.status-line { + margin: 6px 0 0; + font-size: 13px; + color: var(--fg); + min-height: 1.6em; +} + +.evo { margin-top: 12px; } +.bar { + height: 10px; + border-radius: 999px; + background: var(--bar-track); + overflow: hidden; +} +.bar > i { + display: block; + height: 100%; + width: 0; + border-radius: 999px; + background: linear-gradient(90deg, #f0b95a, var(--accent)); + transition: width 0.4s ease; +} +.bar-note { display: flex; justify-content: space-between; margin-top: 5px; gap: 8px; } + +.battle { + margin-top: 10px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--accent); + color: var(--accent-fg, var(--accent)); + font-size: 12.5px; +} + +/* ---------------- 状态条 ---------------- */ + +.care-row { + display: grid; + grid-template-columns: 3.5em 1fr 2.6em; + align-items: center; + gap: 12px; + padding: 5px 0; +} +.care-label { font-size: 12.5px; color: var(--muted); } +.care-val { + font-variant-numeric: tabular-nums; + font-weight: 650; + text-align: right; +} +.care-row .bar { height: 8px; } +.care-row[data-stat="satiety"] .bar-fill { background: linear-gradient(90deg, #f0b95a, #e8a33d); } +.care-row[data-stat="mood"] .bar-fill { background: linear-gradient(90deg, #ef8fb8, #e2609a); } +.care-row[data-stat="energy"] .bar-fill { background: linear-gradient(90deg, #7cc393, #4f9d69); } +.care-row[data-stat="hygiene"] .bar-fill { background: linear-gradient(90deg, #83b3e4, #4d8fd1); } +.care-row[data-stat="bond"] .bar-fill { background: linear-gradient(90deg, #ac94ea, #8b6fd8); } +.care-row.low .care-label, +.care-row.low .care-val { color: var(--warn); } +.care-row.low .bar-fill { filter: saturate(0.6); } + +/* ---------------- 按钮 ---------------- */ + +button { + font: inherit; + padding: 7px 14px; + border-radius: 10px; + cursor: pointer; + border: 1px solid var(--line); + background: var(--card-2); + color: var(--fg); + transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +button:hover:not(:disabled) { + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); +} +button:active:not(:disabled) { transform: translateY(0); } +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button:disabled { opacity: 0.48; cursor: default; } +button.primary { + border-color: var(--accent); + color: var(--accent-fg, var(--accent)); + font-weight: 600; +} +button.equipped { + border-color: var(--ok); + color: var(--ok); +} + +.btn-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + gap: 8px; +} +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } +.hint { margin: 8px 0 0; min-height: 1.5em; } + +/* ---------------- 目标 / 收藏 / 记录 ---------------- */ + +ul { list-style: none; margin: 0; padding: 0; } + +#expedition, +#daily-goals { + min-height: 172px; +} +#daily-goals { + padding-top: 12px; + padding-bottom: 12px; +} + +.goal-list li { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; + font-size: 12px; + border-bottom: 1px dashed var(--line); +} +.goal-list li:last-child { border-bottom: none; } +.goal-list .goal-name { flex: 1; } +.goal-list .goal-progress { font-variant-numeric: tabular-nums; color: var(--muted); } +.goal-list li.done .goal-name { color: var(--ok); } +.goal-list li.done .goal-progress { color: var(--ok); } + +li.ev { + display: flex; + gap: 10px; + align-items: baseline; + padding: 6px 0; + border-bottom: 1px solid var(--line); + font-size: 12.5px; +} +li.ev:last-child { border-bottom: none; } +li.ev time { color: var(--muted); font-variant-numeric: tabular-nums; flex: none; } +li.ev .what { flex: 1; } +li.ev .tok { color: var(--muted); font-variant-numeric: tabular-nums; } + +li.gen { + display: flex; + gap: 10px; + align-items: baseline; + padding: 4px 0; + font-size: 12.5px; +} +li.gen .g { color: var(--muted); flex: none; width: 3.5em; } +li.gen .r { color: var(--muted); font-size: 11px; margin-left: auto; } + +.empty { color: var(--muted); padding: 10px 0; } + +/* ---------------- 管理 ---------------- */ + +#manage { + border-left: 3px solid var(--accent); +} +#manage .btn-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +#manage .btn-row button { min-height: 36px; } +#set-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; align-items: center; } +#set-id { + font: inherit; + width: 7em; + padding: 6px 10px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--bg); + color: var(--fg); +} +#set-id:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +a { color: var(--accent-fg, var(--accent)); text-decoration: none; } +a:hover { text-decoration: underline; } + +#say { min-height: 1.5em; font-size: 12.5px; color: var(--warn); } +#say.ok { color: var(--ok); } + +footer { + max-width: 1080px; + margin: 26px auto 0; +} + +/* ---------------- 响应式 ---------------- */ + +@media (max-width: 900px) { + .hud-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 620px) { + body { padding: 12px 12px 32px; } + section { padding: 13px 14px; border-radius: 14px; } + .habitat-info { padding: 12px 14px 14px; } + .pet-name { font-size: 17px; } + .btn-grid { grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); } + .care-row { grid-template-columns: 3em 1fr 2.4em; gap: 8px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + } +} + +/* ---------------- 命名与图鉴 ---------------- */ +.rename-row { + display: flex; + gap: 8px; + margin-top: 8px; +} +.rename-row input, +.dex-tools input, +.dex-tools select { + font: inherit; + color: var(--fg); + background: color-mix(in srgb, var(--panel) 82%, var(--fg) 4%); + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 10px; +} +.rename-row input { flex: 1; min-width: 0; } +.dex-tools { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} +.dex-tools input { flex: 1 1 180px; } +.dex-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 8px; + max-height: 420px; + overflow: auto; + padding-right: 2px; +} +.dex-card { + display: grid; + gap: 4px; + align-content: start; + padding: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: color-mix(in srgb, var(--panel) 88%, var(--fg) 3%); +} +.dex-card.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; } +.dex-card.locked { opacity: 0.46; filter: saturate(0.5); } +.dex-card b { font-size: 12.5px; overflow-wrap: anywhere; } +.dex-status { font-size: 11px; color: var(--muted); } +.dex-card button { padding: 4px 10px; font-size: 12px; } + +/* ---------------- 档案 tabs ---------------- */ +.tab-bar { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin: -14px -16px 12px; + padding: 0 10px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--card-2) 82%, transparent); + border-radius: 10px 10px 0 0; +} +.tab-btn { + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--muted); + padding: 10px 12px 9px; + box-shadow: none; +} +.tab-btn:hover { color: var(--fg); } +.tab-btn.active { + color: var(--fg); + border-bottom-color: var(--accent); + background: transparent; +} +.tab-panel { + border: 0; + padding: 0; + background: transparent; + box-shadow: none; +} +.tab-panel[hidden] { display: none; } diff --git a/packages/magpie-digital-pet/page/app.html b/packages/magpie-digital-pet/page/app.html new file mode 100644 index 00000000..538c970a --- /dev/null +++ b/packages/magpie-digital-pet/page/app.html @@ -0,0 +1,179 @@ + + + + + +宠物 + + + + + +
+

宠物

+

烧 token,养电子宠物

+
+ +
+
+
+ +
+
+
+ … + + + + +
+

读取中…

+
+ + +
+
+
+
+ + +
+
+ +
+
+ +
+
+
+

状态

+
+ 饱食 +
+ – +
+
+ 心情 +
+ – +
+
+ 精力 +
+ – +
+
+ 清洁 +
+ – +
+
+ 亲密 +
+ – +
+
+ +
+

日常照顾

+
+ + + + + +
+

+
+ +
+

探险

+

还没有进行中的探险。

+
+ + + + +
+
+
+ + +
+ +
+
+ + + +
+
+
+
+

图鉴与切换

+
+ + +
+
+
+
+ + +
+
+ +
+
+ +
+ 数据库与 CLI 同源;页面每 5 秒刷新。
+ 原版引擎来自 @jcendal/opencode-digital-pet(Apache-2.0)。 +
+ + + diff --git a/packages/magpie-digital-pet/page/app.js b/packages/magpie-digital-pet/page/app.js new file mode 100644 index 00000000..f02b8c02 --- /dev/null +++ b/packages/magpie-digital-pet/page/app.js @@ -0,0 +1,895 @@ +// app.js — 宠物主页行为:轮询 /api/state、Canvas 像素渲染、命令接线。 +// 安全约束:动态文本一律 textContent / DOM API,绝不把接口字段拼进 HTML。 +"use strict"; + +const $ = (id) => document.getElementById(id); +const apiHeaders = () => ({ "x-magpie-pet-token": window.PET_TOKEN || "" }); + +const fmt = (n) => { + n = Number(n) || 0; + if (n >= 1e8) return `${(n / 1e8).toFixed(2)}亿`; + if (n >= 1e4) return `${(n / 1e4).toFixed(1)}万`; + return String(n); +}; + +const timeOf = (iso) => { + const d = new Date(iso); + return Number.isFinite(d.getTime()) + ? d.toLocaleString(undefined, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) + : ""; +}; + +const fmtDuration = (ms) => { + const total = Math.max(0, Math.ceil(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + return m > 0 ? `${m}:${String(s).padStart(2, "0")}` : `${s}s`; +}; + +// ---------- theme ---------- + +const prefersDark = matchMedia("(prefers-color-scheme: dark)"); +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); + +function applyTheme() { + const root = document.documentElement.classList; + root.remove("theme-dark", "theme-light"); + let t = null; + try { + t = new URLSearchParams(location.search).get("theme"); + } catch {} + if (t === "dark" || t === "light") root.add(`theme-${t}`); + else root.add(prefersDark.matches ? "theme-dark" : "theme-light"); +} +applyTheme(); +prefersDark.addEventListener?.("change", applyTheme); +reducedMotion.addEventListener?.("change", () => { + restartAnimation(); + renderScene(); +}); + +const motionOK = () => !reducedMotion.matches; +const isDark = () => document.documentElement.classList.contains("theme-dark"); + +// ---------- deterministic hashing ---------- + +function hashString(s) { + let h = 2166136261; + for (const ch of String(s)) { + h ^= ch.codePointAt(0); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ---------- canvas scene ---------- + +const canvas = $("pet-canvas"); +const ctx = canvas.getContext("2d"); +const CW = canvas.width; +const CH = canvas.height; +const GROUND_Y = CH - 22; +const SPRITE_COLS = 16; +const SPRITE_ROWS = 8; +const PX = Math.floor(Math.min((CW - 48) / SPRITE_COLS, (GROUND_Y - 16) / SPRITE_ROWS)); + +let tick = 0; +let lastState = null; +let particlesCache = { key: null, list: [] }; +let override = null; // { names: string[], until: number } + +const EXPEDITION_LABELS = { patrol: "短巡逻", field: "数据原野", night: "深夜远征" }; +const DECORATION_PRICES = { + meadow: 10, + night: 25, + terminal: 40, + ocean: 60, + sunset: 80, + snow: 120, + sakura: 160, + void: 200, +}; +const GOAL_LABELS = { + care: "照顾宠物 2 次", + expedition: "完成 1 次探险", + feed: "自动进食 10 个请求", +}; +const ACTION_FRAMES = { + "pet-feed-snack": ["eat_1", "eat_2"], + "pet-play": ["happy", "walk_1"], + "pet-clean": ["happy", "walk_2"], + "pet-rest": ["sleep_1", "sleep_2"], + "pet-pet-egg": ["walk_2", "walk_1"], +}; +const ACTION_EFFECTS = { + "pet-feed-snack": { color: "#f59e0b", shape: "crumb" }, + "pet-play": { color: "#facc15", shape: "star" }, + "pet-clean": { color: "#38bdf8", shape: "drop" }, + "pet-rest": { color: "#a78bfa", shape: "sleep" }, + "pet-pet-egg": { color: "#fb7185", shape: "heart" }, +}; +let actionEffect = null; + +function habitatVariant() { + const theme = lastState?.collection?.equippedTheme; + return Object.hasOwn(DECORATION_PRICES, theme) ? theme : ""; +} + +function spriteHue(sprite) { + if (habitatVariant() === "terminal") return 135; // 终端荧光绿 + return hashString(sprite || "egg") % 360; +} + +function particlesFor(key) { + if (particlesCache.key === key) return particlesCache.list; + const rnd = mulberry32(hashString(`particles:${key}`)); + const list = []; + for (let i = 0; i < 14; i += 1) { + list.push({ + x: 12 + rnd() * (CW - 24), + y: 8 + rnd() * (GROUND_Y - 30), + r: 1 + rnd() * 1.8, + phase: rnd() * Math.PI * 2, + }); + } + particlesCache = { key, list }; + return list; +} + +function charShade(ch, hue) { + const dark = isDark(); + if (ch === "█") return `hsl(${hue} ${dark ? 55 : 60}% ${dark ? 62 : 45}%)`; + if (ch === "▀") return `hsl(${hue} ${dark ? 45 : 50}% ${dark ? 76 : 62}%)`; + if (ch === "▄") return `hsl(${hue} ${dark ? 60 : 55}% ${dark ? 46 : 33}%)`; + return null; +} + +function drawParticles(key) { + const variant = habitatVariant(); + const dark = isDark(); + for (const p of particlesFor(key)) { + const twinkle = motionOK() ? 0.35 + 0.3 * Math.sin(tick * 0.7 + p.phase) : 0.5; + let color; + if (variant === "terminal") color = `hsla(135 80% 60% / ${twinkle})`; + else if (variant === "night" || dark) color = `hsla(45 90% 80% / ${twinkle})`; + else color = `hsla(35 70% 55% / ${twinkle * 0.8})`; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawGround() { + const variant = habitatVariant(); + const dark = isDark(); + let strip; + let shadow; + if (variant === "terminal") { + strip = "hsla(135 70% 40% / 0.5)"; + shadow = "hsla(135 80% 50% / 0.25)"; + } else if (variant === "night" || dark) { + strip = "rgba(0, 0, 0, 0.35)"; + shadow = "rgba(0, 0, 0, 0.45)"; + } else { + strip = "rgba(90, 70, 35, 0.18)"; + shadow = "rgba(90, 70, 35, 0.22)"; + } + ctx.fillStyle = strip; + ctx.fillRect(0, GROUND_Y, CW, CH - GROUND_Y); + // 宠物脚下的柔和阴影 + ctx.fillStyle = shadow; + ctx.beginPath(); + ctx.ellipse(CW / 2, GROUND_Y + 2, (SPRITE_COLS * PX) / 2.6, 5, 0, 0, Math.PI * 2); + ctx.fill(); +} + +function drawGlow(hue) { + // 蛋的呼吸光晕:跟着 tick 轻轻起伏 + const alpha = motionOK() ? 0.14 + 0.09 * Math.sin(tick * 0.9) : 0.18; + const cx = CW / 2; + const cy = GROUND_Y - (SPRITE_ROWS * PX) / 2; + const r = (SPRITE_COLS * PX) / 1.7; + const grad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r); + grad.addColorStop(0, `hsla(${hue} 80% 65% / ${alpha})`); + grad.addColorStop(1, `hsla(${hue} 80% 65% / 0)`); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); +} + +function drawContent(content, hue, bob) { + const lines = content.split("\n"); + const rows = lines.length; + const cols = Math.max(...lines.map((l) => [...l].length)); + const ox = Math.round((CW - cols * PX) / 2); + const oy = GROUND_Y - rows * PX + bob; + lines.forEach((line, row) => { + [...line].forEach((ch, col) => { + const color = charShade(ch, hue); + if (!color) return; + ctx.fillStyle = color; + ctx.fillRect(ox + col * PX, oy + row * PX, PX, PX); + }); + }); +} + +function drawActionEffect() { + if (!actionEffect) return; + const now = Date.now(); + const elapsed = now - actionEffect.startedAt; + const total = 1400; + if (elapsed >= total) { + actionEffect = null; + return; + } + const progress = elapsed / total; + const style = actionEffect.style; + const rnd = mulberry32(hashString(`${actionEffect.kind}:${actionEffect.startedAt}`)); + ctx.save(); + ctx.globalAlpha = Math.max(0, 1 - progress * 0.85); + ctx.fillStyle = style.color; + ctx.strokeStyle = style.color; + ctx.lineWidth = 2; + for (let i = 0; i < 12; i += 1) { + const angle = rnd() * Math.PI * 2; + const radius = 28 + rnd() * 52 + progress * 28; + const x = CW / 2 + Math.cos(angle) * radius; + const y = GROUND_Y - 38 - Math.sin(angle) * radius * 0.42 - progress * 34; + const size = 3 + rnd() * 3; + if (style.shape === "star") { + ctx.beginPath(); + for (let p = 0; p < 8; p += 1) { + const a = (Math.PI / 4) * p; + const r = p % 2 === 0 ? size * 1.7 : size * 0.65; + const px = x + Math.cos(a) * r; + const py = y + Math.sin(a) * r; + if (p === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fill(); + } else if (style.shape === "drop") { + ctx.beginPath(); + ctx.ellipse(x, y, size * 0.7, size * 1.3, 0, 0, Math.PI * 2); + ctx.fill(); + } else if (style.shape === "sleep") { + ctx.font = `700 ${Math.round(size * 5)}px system-ui`; + ctx.fillText("Z", x, y); + } else if (style.shape === "heart") { + ctx.beginPath(); + ctx.arc(x - size * 0.45, y, size * 0.55, 0, Math.PI * 2); + ctx.arc(x + size * 0.45, y, size * 0.55, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(x - size, y + size * 0.25); + ctx.lineTo(x + size, y + size * 0.25); + ctx.lineTo(x, y + size * 1.35); + ctx.closePath(); + ctx.fill(); + } else { + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} + +// 缺帧兜底:按 node.id + stage 生成稳定的对称像素团,绝不空白。 +function drawFallback(key, hue, bob) { + const rnd = mulberry32(hashString(`fallback:${key}`)); + const rows = SPRITE_ROWS; + const half = 6; + const grid = []; + for (let r = 0; r < rows; r += 1) { + const rowArr = []; + for (let c = 0; c < half; c += 1) { + const dx = (c - (half - 0.5)) / half; + const dy = (r - (rows - 1) / 2) / (rows / 2); + const inside = dx * dx + dy * dy < 1; + rowArr.push(inside && rnd() < 0.82 ? (rnd() < 0.6 ? "█" : rnd() < 0.5 ? "▀" : "▄") : " "); + } + grid.push(rowArr.join("") + [...rowArr].reverse().join("")); + } + drawContent(grid.join("\n"), hue, bob); +} + +function pickFrameNames(state, frames) { + const ok = (n) => typeof frames?.[n] === "string" && frames[n].length > 0; + const regular = () => { + const care = state?.care?.state; + if (!care) return ["walk_1", "walk_2"]; + if (care.energy < 25) return ["sleep_1", "sleep_2", "walk_1", "walk_2"]; + if (care.hygiene < 25) return ["injured_1", "injured_2", "walk_1", "walk_2"]; + if (care.satiety < 25) return ["angry", "refuse", "walk_1", "walk_2"]; + if (care.mood >= 75) return ["happy", "walk_1", "walk_2"]; + return ["walk_1", "walk_2"]; + }; + if (override && Date.now() < override.until) { + // 缺失姿态时回退到行走/待机常规帧,而不是直接走像素兜底 + const names = override.names.filter(ok); + return names.length > 0 ? names : regular().filter(ok); + } + return regular().filter(ok); +} + +function renderScene() { + const s = lastState; + ctx.clearRect(0, 0, CW, CH); + const sprite = s?.partner?.node?.sprite || "egg"; + const stage = s?.partner?.node?.stage ?? 0; + const hue = spriteHue(sprite); + drawParticles(sprite); + drawGround(); + const isEgg = stage === 0 || sprite === "egg"; + if (isEgg) drawGlow(hue); + const bob = motionOK() && isEgg ? Math.round(Math.sin(tick * 0.9) * 2) : 0; + const frameSet = s?.frames || {}; + const available = pickFrameNames(s, frameSet); + if (available.length > 0) { + drawContent(frameSet[available[tick % available.length]], hue, bob); + } else if (s?.partner) { + drawFallback(`${s.partner.node.id}:${stage}`, hue, bob); + } + drawActionEffect(); +} + +// 低帧率循环:2.5fps 足够像素动画;减少动态偏好时完全停止循环。 +let animTimer = null; +function restartAnimation() { + if (animTimer) { + clearInterval(animTimer); + animTimer = null; + } + if (motionOK()) { + animTimer = setInterval(() => { + tick += 1; + renderScene(); + }, 400); + } +} +restartAnimation(); + +// ---------- status & DOM painting ---------- + +function say(msg, ok) { + const el = $("say"); + el.textContent = msg || ""; + el.className = ok ? "ok" : ""; +} + +function statusText(s) { + if (!s.ok) return `读不到数据库:${s.error || "?"}`; + if (!s.partner) return "还没有搭档 —— 到「管理」里孵一颗新蛋吧。"; + const exp = s.expedition; + if (exp && exp.status === "running") { + const left = exp.endsAt - Date.now(); + if (left > 0) return `它正在「${EXPEDITION_LABELS[exp.kind] || exp.kind}」探险,还要 ${fmtDuration(left)}。`; + return "探险结束了,它带着收获回来了!"; + } + const care = s.care?.state; + if (!care) return ""; + const label = s.care.summary?.moodLabel || ""; + const hints = []; + if (care.satiety < 25) hints.push("肚子有点饿"); + if (care.mood < 25) hints.push("想找人玩"); + if (care.energy < 25) hints.push("困得睁不开眼"); + if (care.hygiene < 25) hints.push("该洗个澡了"); + const tail = hints.length ? `——${hints.join(",")}。` : "。"; + return `它现在${label}${tail}`; +} + +function paintHero(s) { + const p = s.ok ? s.partner : null; + if (!p) { + $("pet-name").textContent = s.ok ? "还没有搭档" : "离线"; + $("pet-jp").textContent = ""; + $("pet-stage-badge").textContent = ""; + $("pet-pin-badge").hidden = true; + $("pet-frozen-badge").hidden = true; + $("evo-fill").style.width = "0%"; + $("evo-text").textContent = ""; + $("evo-pct").textContent = ""; + $("battle").hidden = true; + return; + } + $("pet-name").textContent = s.petName || p.node.nameEn; + $("pet-jp").textContent = s.petName ? `${p.node.nameEn} · ${p.node.nameJp}` : p.node.nameJp; + const renameInput = $("pet-rename-input"); + if (renameInput && document.activeElement !== renameInput) renameInput.value = s.petName || ""; + $("pet-stage-badge").textContent = `gen ${p.generation} · ${p.stageName}`; + $("pet-pin-badge").hidden = !s.pinned; + $("pet-frozen-badge").hidden = !s.frozen; + $("evo-fill").style.width = `${Math.max(1.5, p.pct).toFixed(1)}%`; + $("evo-text").textContent = `本阶段 ${fmt(p.gauge)} / ${fmt(p.threshold)} tokens`; + $("evo-pct").textContent = `${p.pct.toFixed(2)}%`; + $("battle").hidden = !p.battle; + if (p.battle) $("battle-foe").textContent = p.battle.nameEn; +} + +function paintCareBars(s) { + const care = s.ok ? s.care?.state : null; + document.querySelectorAll("#care-bars .care-row").forEach((row) => { + const stat = row.dataset.stat; + const value = care ? care[stat] : null; + const fill = row.querySelector(".bar-fill"); + const val = row.querySelector(".care-val"); + if (value == null || !Number.isFinite(value)) { + fill.style.width = "0%"; + val.textContent = "–"; + row.classList.remove("low"); + return; + } + fill.style.width = `${Math.max(0, Math.min(100, value))}%`; + val.textContent = String(Math.round(value)); + row.classList.toggle("low", stat !== "bond" && value < 25); + }); +} + +function paintDaily(s) { + const ul = $("daily-list"); + ul.replaceChildren(); + const goals = s.ok ? s.daily?.goals : null; + if (!goals || !goals.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.textContent = "还没有搭档,暂无每日目标。"; + ul.append(li); + return; + } + for (const goal of goals) { + const li = document.createElement("li"); + if (goal.done) li.classList.add("done"); + const name = document.createElement("span"); + name.className = "goal-name"; + name.textContent = GOAL_LABELS[goal.id] || goal.id; + const progress = document.createElement("span"); + progress.className = "goal-progress"; + progress.textContent = goal.done ? `${goal.target}/${goal.target} · 已完成` : `${goal.progress}/${goal.target}`; + li.append(name, progress); + ul.append(li); + } +} + +function paintCollection(s) { + const c = s.ok ? s.collection : null; + $("fragments").textContent = String(c?.fragments ?? 0); + const total = 6; + const got = c?.achievements?.length ?? 0; + $("achievements").textContent = `成就 ${got}/${total}`; + const equipped = c?.equippedTheme || ""; + const unlocked = new Set(c?.unlocked || []); + document.querySelectorAll('[data-command="pet-decoration-equip"]').forEach((btn) => { + const id = btn.dataset.args; + const price = DECORATION_PRICES[id] ?? 0; + btn.classList.toggle("equipped", equipped === id); + if (equipped === id) btn.textContent = `${btn.dataset.label || ""} · 已装备`; + else if (unlocked.has(id)) btn.textContent = `${btn.dataset.label || ""} · 点击装备`; + else btn.textContent = `${btn.dataset.label || ""} · ${price} 碎片`; + }); +} + +let dexPaintKey = ""; +function paintDex(s) { + const grid = $("dex-grid"); + if (!grid) return; + const query = ($("dex-search")?.value || "").trim().toLowerCase(); + const filter = $("dex-filter")?.value || "unlocked"; + const items = (s.ok && Array.isArray(s.dex) ? s.dex : []).filter((item) => { + if (filter === "unlocked" && !item.unlocked) return false; + if (filter === "locked" && item.unlocked) return false; + if (!query) return true; + return [item.id, item.nameEn, item.nameJp].some((v) => String(v || "").toLowerCase().includes(query)); + }); + const key = JSON.stringify([query, filter, items.map((i) => [i.id, i.unlocked, i.current])]); + if (key === dexPaintKey) return; + dexPaintKey = key; + grid.replaceChildren(); + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "没有匹配的图鉴。"; + grid.append(empty); + return; + } + for (const item of items) { + const card = document.createElement("div"); + card.className = `dex-card${item.unlocked ? "" : " locked"}${item.current ? " current" : ""}`; + const title = document.createElement("b"); + title.textContent = item.nameEn; + const jp = document.createElement("span"); + jp.className = "muted small"; + jp.textContent = `${item.nameJp} · ${item.id} · stage ${item.stage}`; + const status = document.createElement("span"); + status.className = "dex-status"; + if (!item.unlocked) { + status.textContent = "未解锁"; + card.append(title, jp, status); + grid.append(card); + continue; + } + status.textContent = item.current ? "当前宠物" : "已解锁"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.dataset.command = "digital-pet-set"; + btn.dataset.args = item.id; + btn.textContent = item.current ? "正在展示" : "切换"; + btn.title = "切换会展示并钉住该形态,暂时停止生长;可在管理里取消钉住。"; + btn.disabled = item.current; + btn.addEventListener("click", () => command("digital-pet-set", item.id)); + card.append(title, jp, status, btn); + grid.append(card); + } +} + +function paintExpedition(s) { + const el = $("expedition-status"); + const exp = s.ok ? s.expedition : null; + if (!exp) { + el.textContent = "还没有进行中的探险。派它出去巡逻能带回数据碎片。"; + return; + } + const left = exp.endsAt - Date.now(); + if (left > 0) { + el.textContent = `「${EXPEDITION_LABELS[exp.kind] || exp.kind}」进行中,剩余 ${fmtDuration(left)}。`; + } else { + el.textContent = `「${EXPEDITION_LABELS[exp.kind] || exp.kind}」已完成,快领取奖励!`; + } +} + +function paintLists(s) { + const ev = $("events"); + ev.replaceChildren(); + if (!s.ok || !s.events || !s.events.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.textContent = s.ok ? "还没有记录。" : "读不到数据库。"; + ev.append(li); + } else { + for (const e of s.events) { + const li = document.createElement("li"); + li.className = "ev"; + const time = document.createElement("time"); + time.textContent = timeOf(e.createdAt); + const what = document.createElement("span"); + what.className = "what"; + what.textContent = + e.kind === "spawned" ? `孵出 ${e.node.nameEn}` : + e.kind === "usage_applied" ? `${e.node.nameEn} 进食` : + e.kind; + const tok = document.createElement("span"); + tok.className = "tok"; + tok.textContent = e.tokenDelta != null ? `+${fmt(e.tokenDelta)}` : ""; + li.append(time, what, tok); + ev.append(li); + } + } + const gens = $("gens"); + gens.replaceChildren(); + if (!s.ok || !s.partners || !s.partners.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.textContent = "还没有搭档。"; + gens.append(li); + } else { + for (const g of s.partners.slice().reverse()) { + const li = document.createElement("li"); + li.className = "gen"; + const gen = document.createElement("span"); + gen.className = "g"; + gen.textContent = `gen ${g.generation}`; + const name = document.createElement("span"); + name.textContent = `${g.node.nameEn}${g.retired ? "" : " · 现役"}`; + const when = document.createElement("span"); + when.className = "r"; + when.textContent = timeOf(g.createdAt); + li.append(gen, name, when); + gens.append(li); + } + } +} + +function paint(s) { + lastState = s; + if (s.ok) { + $("sub").textContent = + `累计已喂 ${s.fedRequests} 个请求 · ${fmt(s.fedTokens)} tokens` + + (s.fedCost > 0 ? ` · $${s.fedCost.toFixed(2)}` : ""); + } + const habitat = $("habitat"); + habitat.classList.remove(...Object.keys(DECORATION_PRICES).map((id) => `habitat--${id}`)); + const variant = habitatVariant(); + if (variant) habitat.classList.add(`habitat--${variant}`); + $("status-line").textContent = statusText(s); + paintHero(s); + paintCareBars(s); + paintDaily(s); + paintCollection(s); + paintDex(s); + paintExpedition(s); + paintLists(s); + updateButtons(); + renderScene(); +} + +// ---------- buttons: cooldown / availability ---------- + +const careButtons = () => [...document.querySelectorAll("#actions [data-command]")]; +const expeditionStartButtons = () => [...document.querySelectorAll('[data-command="pet-expedition-start"]')]; + +function updateButtons() { + const s = lastState; + const now = Date.now(); + const partner = s?.ok ? s.partner : null; + const care = s?.ok ? s.care?.state : null; + const exp = s?.ok ? s.expedition : null; + const expRunning = !!(exp && exp.status === "running"); + const expFinished = expRunning && exp.endsAt <= now; + + const hint = $("actions-hint"); + for (const btn of careButtons()) { + const base = btn.dataset.label; + const key = btn.dataset.cooldown; + if (btn.id === "btn-pet-egg") { + const isEgg = (partner?.node?.stage ?? -1) === 0; + btn.hidden = !!partner && !isEgg; + } + if (!s || !s.ok) { + btn.disabled = true; + btn.textContent = base; + continue; + } + if (!partner) { + btn.disabled = true; + btn.textContent = base; + btn.title = "先孵一颗蛋"; + continue; + } + if (expRunning) { + btn.disabled = true; + btn.textContent = base; + btn.title = "探险中,等它回来吧"; + continue; + } + const until = care?.cooldowns?.[key] ?? 0; + if (until > now) { + btn.disabled = true; + btn.textContent = `${base} · ${fmtDuration(until - now)}`; + btn.title = "冷却中"; + continue; + } + if (key === "play" && care && care.energy < 20) { + btn.disabled = true; + btn.textContent = base; + btn.title = "它现在太困了,先休息一下"; + continue; + } + btn.disabled = false; + btn.textContent = base; + btn.title = ""; + } + if (!s || !s.ok) hint.textContent = ""; + else if (!partner) hint.textContent = "先孵一颗蛋,再来照顾它。"; + else if (expRunning) hint.textContent = "它出门探险了,回来再照顾吧。"; + else hint.textContent = ""; + + for (const btn of expeditionStartButtons()) { + const base = btn.dataset.label; + if (!s || !s.ok || !partner) { + btn.disabled = true; + btn.textContent = base; + } else if (expRunning) { + btn.disabled = true; + btn.textContent = base; + btn.title = "同一时刻只能有一场探险"; + } else if (care && care.energy < 15) { + btn.disabled = true; + btn.textContent = base; + btn.title = "它现在太困了,先休息一下"; + } else { + btn.disabled = false; + btn.textContent = base; + btn.title = ""; + } + } + + const claim = $("btn-claim"); + claim.hidden = !expRunning; + if (expRunning) { + claim.disabled = !expFinished; + claim.textContent = expFinished ? "领取探险奖励" : `探险中 · ${fmtDuration(exp.endsAt - now)}`; + } + + const fragments = s?.ok ? s.collection?.fragments ?? 0 : 0; + const unlocked = new Set(s?.ok ? s.collection?.unlocked || [] : []); + document.querySelectorAll('[data-command="pet-decoration-equip"]').forEach((btn) => { + const id = btn.dataset.args; + const owned = unlocked.has(id); + btn.disabled = !s?.ok || (!owned && fragments < (DECORATION_PRICES[id] ?? Infinity)); + btn.title = btn.disabled && s?.ok ? "碎片不够,去探险赚一点吧" : ""; + }); + + const spawnBtn = document.querySelector('[data-command="digital-pet-spawn"]'); + if (spawnBtn) { + if (!s || !s.ok) { + spawnBtn.disabled = true; + spawnBtn.title = ""; + } else if (!partner) { + spawnBtn.disabled = false; + spawnBtn.title = "孵出第一颗蛋"; + } else { + const stage = partner.engineNode?.stage ?? partner.node?.stage ?? 0; + spawnBtn.disabled = stage < 2; + spawnBtn.title = stage < 2 + ? "当前伙伴达到 baby II 后才能孵下一颗蛋" + : "当前伙伴会退役,需要二次确认"; + } + } + + // 状态文案里的探险倒计时也随秒针刷新 + if (s?.ok) $("status-line").textContent = statusText(s); + if (s?.ok) paintExpedition(s); +} + +// ---------- commands ---------- + +let busy = false; +let spawnConfirmUntil = 0; + +const commandButtons = () => document.querySelectorAll("[data-command]"); + +async function command(name, args) { + if (busy) return; + busy = true; + commandButtons().forEach((b) => (b.disabled = true)); + try { + const r = await fetch("/api/command", { + method: "POST", + headers: { "content-type": "application/json", ...apiHeaders() }, + body: JSON.stringify({ name, args }), + }); + const j = await r.json().catch(() => null); + if (!r.ok) { + say(j?.error || `操作失败(HTTP ${r.status})`); + return; + } + const toast = (j?.toasts || []) + .map((t) => [t.title, t.message].filter(Boolean).join(" — ")) + .filter(Boolean) + .join(";"); + say(toast || j?.text || "完成", true); + if (ACTION_FRAMES[name]) { + override = { names: ACTION_FRAMES[name], until: Date.now() + 2400 }; + } + if (ACTION_EFFECTS[name]) { + actionEffect = { kind: name, style: ACTION_EFFECTS[name], startedAt: Date.now() }; + } + if (j?.state) paint(j.state); + } catch (e) { + say(`命令失败:${e?.message ?? e}`); + } finally { + busy = false; + // 只恢复命令按钮,档案 tab 不参与命令禁用状态 + commandButtons().forEach((b) => (b.disabled = false)); + updateButtons(); + } +} + +document.querySelectorAll("[data-command]").forEach((btn) => { + // 记录静态基础文案,倒计时/装备状态刷新时还原; + // 装饰按钮的初始文案带价格后缀,只保留名称部分 + btn.dataset.label = + btn.dataset.command === "pet-decoration-equip" + ? btn.textContent.split(" · ")[0] + : btn.textContent; + btn.addEventListener("click", () => { + const name = btn.dataset.command; + if (name === "digital-pet-spawn") { + if (Date.now() > spawnConfirmUntil) { + spawnConfirmUntil = Date.now() + 4000; + btn.textContent = "再点一次确认孵新蛋"; + say("当前宠物会退役,4 秒内再次点击确认孵新蛋。"); + setTimeout(() => { + if (Date.now() > spawnConfirmUntil) { + btn.textContent = btn.dataset.label; + spawnConfirmUntil = 0; + } + }, 4100); + return; + } + spawnConfirmUntil = 0; + btn.textContent = btn.dataset.label; + command(name, btn.dataset.args); + return; + } + if (name === "pet-rename") { + const value = $("pet-rename-input").value.trim().replace(/\s+/g, " "); + if (value.length < 1 || value.length > 20) { + say("名字需要 1–20 个字符。"); + return; + } + command(name, value); + return; + } + if (name === "digital-pet-set") { + const id = $("set-id").value.trim(); + if (!/^[0-9]-[0-9]{1,3}$/.test(id)) { + say("ID 形如 4-001"); + return; + } + command(name, id); + return; + } + if (name === "pet-expedition-claim") { + const expId = lastState?.expedition?.expeditionId; + if (!expId) { + say("现在没有可结算的探险。"); + return; + } + command(name, expId); + return; + } + command(name, btn.dataset.args); + }); +}); + +$("dex-search")?.addEventListener("input", () => { + dexPaintKey = ""; + if (lastState) paintDex(lastState); +}); +$("dex-filter")?.addEventListener("change", () => { + dexPaintKey = ""; + if (lastState) paintDex(lastState); +}); + +// ---------- archive tabs ---------- + +document.querySelectorAll(".tab-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.tab; + document.querySelectorAll(".tab-btn").forEach((b) => { + const active = b === btn; + b.classList.toggle("active", active); + b.setAttribute("aria-selected", active ? "true" : "false"); + }); + document.querySelectorAll(".tab-panel").forEach((panel) => { + panel.hidden = panel.id !== `panel-${tab}`; + }); + }); +}); + +// ---------- polling ---------- + +async function load() { + try { + const r = await fetch("/api/state", { cache: "no-store", headers: apiHeaders() }); + if (r.status === 403) { + say("页面口令过期,正在刷新…"); + if (!sessionStorage.getItem("pet-token-reloaded")) { + sessionStorage.setItem("pet-token-reloaded", "1"); + setTimeout(() => location.reload(), 300); + } + return; + } + sessionStorage.removeItem("pet-token-reloaded"); + paint(await r.json()); + } catch (e) { + say(`刷新失败:${e?.message ?? e}`); + } +} + +load(); +setInterval(load, 5000); +// 秒针:只刷新冷却/探险倒计时文案与禁用状态,不触碰布局 +setInterval(updateButtons, 1000); diff --git a/packages/magpie-digital-pet/page/boot.js b/packages/magpie-digital-pet/page/boot.js new file mode 100644 index 00000000..e309fec5 --- /dev/null +++ b/packages/magpie-digital-pet/page/boot.js @@ -0,0 +1,25 @@ +// 带路页:把 magpie 页面框架导航到插件的本地服务。 +// 页面 CSP 是 connect-src 'none',唯一能做的联网动作就是跳转自己。 +// bridge 偶发不应答时也不能停在加载页:最多等 350ms,然后直接盲跳。 +const withTimeout = (promise, ms) => + Promise.race([ + Promise.resolve(promise).catch(() => null), + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); + +(async () => { + const DEFAULT_PORT = 3571; + let port = DEFAULT_PORT; + + const options = await withTimeout(window.magpie?.options?.get?.(), 350); + if (options && Number.isFinite(Number(options.port)) && Number(options.port) > 0) { + port = Number(options.port); + } + + const q = new URLSearchParams(); + const theme = await withTimeout(window.magpie?.ready, 350); + if (theme && typeof theme.dark === "boolean") q.set("theme", theme.dark ? "dark" : "light"); + if (theme && typeof theme.lang === "string") q.set("lang", theme.lang); + + location.replace("http://127.0.0.1:" + port + "/?" + q.toString()); +})(); diff --git a/packages/magpie-digital-pet/page/icon.svg b/packages/magpie-digital-pet/page/icon.svg new file mode 100644 index 00000000..160b6b3e --- /dev/null +++ b/packages/magpie-digital-pet/page/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/magpie-digital-pet/page/index.html b/packages/magpie-digital-pet/page/index.html new file mode 100644 index 00000000..65740c7d --- /dev/null +++ b/packages/magpie-digital-pet/page/index.html @@ -0,0 +1,24 @@ + + + + + +宠物 + + + +
宠物正在连接到宠物服务…
+ + + + diff --git a/packages/magpie-digital-pet/tests/docs-contract.test.mjs b/packages/magpie-digital-pet/tests/docs-contract.test.mjs new file mode 100644 index 00000000..ec49b147 --- /dev/null +++ b/packages/magpie-digital-pet/tests/docs-contract.test.mjs @@ -0,0 +1,132 @@ +// tests/docs-contract.test.mjs +// Static contract for user-facing docs and the CLI summary (Task 7): +// README and the settings panel agree on the real database path +// (<插件目录>\data\pet.db), README documents the new care gameplay, the +// package version is bumped to 0.4.0, and the CLI status command prints a +// read-only care summary without gaining any new write command branches. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = dirname(HERE); + +const readme = readFileSync(join(ROOT, "README.md"), "utf8"); +const panel = readFileSync(join(ROOT, "ui", "panel.html"), "utf8"); +const cli = readFileSync(join(ROOT, "cli", "pet-cli.mjs"), "utf8"); +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); +const { defaultDatabasePath, legacyDatabasePath } = await import("../lib/pet.adapter.mjs"); + +let failures = 0; +function test(name, fn) { + try { + fn(); + console.log(`PASS ${name}`); + } catch (error) { + failures += 1; + console.error(`FAIL ${name}`); + console.error(error.message); + } +} + +test("package.json follows the page-plugin contract and bundles no restricted art", () => { + assert.equal(pkg.name, "@magpie-community/page-digital-pet"); + assert.equal(pkg.version, "0.6.0"); + assert.match(pkg.description, /@jcendal\/opencode-digital-pet 0\.18\.0/, "must keep upstream attribution"); + assert.match(pkg.description, /never touches agents' contexts/i, "must keep the no-agent-context promise"); + assert.equal(pkg.repository.url, "git+https://github.com/magpie-community/plugins.git"); + assert.equal(pkg.repository.directory, "packages/magpie-digital-pet"); + assert.equal(pkg.magpie.page.service.port, 3571); + assert.equal(pkg.magpie.page.service.option, "port"); + assert.equal(pkg.dependencies["@jcendal/opencode-digital-pet"], "0.18.0"); + for (const keyword of ["digimon", "tamagotchi"]) { + assert.ok(!pkg.keywords.includes(keyword), `keyword ${keyword} must be removed`); + } +}); + +test("README states the save lives in the user data directory, separate from plugin code", () => { + assert.ok(readme.includes("%APPDATA%\\magpie-digital-pet\\pet.db"), "README must name the Windows save path"); + assert.ok(readme.includes("与插件代码目录分离"), "README must document code/save separation"); + assert.ok(readme.includes("自动从旧版"), "README must document legacy migration"); +}); + +test("default save path is outside the plugin code directory", () => { + assert.notEqual(defaultDatabasePath(), legacyDatabasePath()); + assert.ok(defaultDatabasePath().includes("magpie-digital-pet")); + assert.ok(legacyDatabasePath().includes(join("data", "pet.db"))); +}); + +test("plugin does not write the upstream OpenCode config", async () => { + const adapterSource = readFileSync(join(ROOT, "lib", "pet.adapter.mjs"), "utf8"); + assert.ok(!adapterSource.includes("opencode-digital-pet.json"), "must not target the upstream global config file"); + assert.ok(!adapterSource.includes("ensureGrowthSettings"), "must not rewrite upstream growth settings"); +}); + +test("README documents the new care gameplay on the sidebar pet page", () => { + for (const keyword of ["日常照顾", "探险", "每日目标", "装饰", "宠物", "侧边栏"]) { + assert.ok(readme.includes(keyword), `README must mention ${keyword}`); + } +}); + +test("README test section lists every module test plus selftest", () => { + for (const file of [ + "pet-frames.test.mjs", + "pet-care-rules.test.mjs", + "pet-progression-rules.test.mjs", + "pet-care-store.test.mjs", + "pet-server-care.test.mjs", + "page-contract.test.mjs", + "docs-contract.test.mjs", + "e2e-page.test.mjs", + "selftest.mjs", + ]) { + assert.ok(readme.includes(file), `README test list missing ${file}`); + } + assert.ok(readme.includes("PET_E2E_PLAYWRIGHT"), "README must document the optional browser env"); + assert.ok(readme.includes("PET_E2E_EDGE"), "README must document the optional browser executable env"); +}); + +test("README keeps the original CLI command documentation", () => { + for (const cmd of ["status", "spawn", "freeze", "unfreeze", "events", "partners", "set 4-001"]) { + assert.ok(readme.includes(cmd), `README must keep documenting \`${cmd}\``); + } +}); + +test("settings panel points at the user-data database, separate from plugin code", () => { + assert.ok(panel.includes("%APPDATA%\\magpie-digital-pet\\pet.db"), "panel must name the user-data save path"); + assert.ok(panel.includes("个人存档与插件代码分离"), "panel must document code/save separation"); + assert.ok(panel.includes("dbPath"), "panel must mention the dbPath override"); +}); + +test("settings panel explains care and expeditions happen on the sidebar pet page", () => { + assert.match(panel, /宠物页/, "panel must point to the sidebar pet page"); + assert.match(panel, /照顾/, "panel must mention care actions"); + assert.match(panel, /探险/, "panel must mention expeditions"); +}); + +test("CLI status prints the four read-only summary lines", () => { + for (const label of ["care", "bond", "fragments", "expedition"]) { + const re = new RegExp(`console\\.log\\(\\\`${label}\\s`); + assert.ok(re.test(cli), `pet-cli.mjs must print a \`${label}\` summary line`); + } +}); + +test("CLI summary is read-only: no new write command branches", () => { + const cases = [...cli.matchAll(/case "([^"]+)":/g)].map((m) => m[1]); + assert.deepEqual( + [...cases].sort(), + ["events", "freeze", "partners", "set", "spawn", "status", "unfreeze"], + "the command switch must keep exactly the original seven commands", + ); + for (const writeCall of ["performAction(", "startExpedition(", "claimExpedition(", "equipDecoration("]) { + assert.ok(!cli.includes(writeCall), `CLI must not call ${writeCall}`); + } +}); + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log("\nall tests passed"); diff --git a/packages/magpie-digital-pet/tests/e2e-page.test.mjs b/packages/magpie-digital-pet/tests/e2e-page.test.mjs new file mode 100644 index 00000000..9da9beb6 --- /dev/null +++ b/packages/magpie-digital-pet/tests/e2e-page.test.mjs @@ -0,0 +1,422 @@ +// tests/e2e-page.test.mjs +// End-to-end regression and real-page acceptance (Task 8). +// +// Core section (always runs): boots the page server over a throwaway COPY of +// the real data/pet.db and walks one full care loop through the public API: +// state -> snack -> clean -> expedition start -> (test fast-forwards ends_at +// in the copy, production durations untouched) -> claim -> state again. +// All requests use node:http straight to 127.0.0.1: the shell has HTTP_PROXY +// set with an empty NO_PROXY, so Bun's fetch would be answered by the proxy +// and could neither prove the server up nor down. +// +// Browser section (opt-in): renders the real dashboard in the local Edge via +// playwright-core, without adding either as a project dependency. It only +// runs when BOTH env vars point at local files: +// PET_E2E_PLAYWRIGHT absolute path to playwright-core/index.mjs +// PET_E2E_EDGE absolute path to msedge.exe +// PET_E2E_ARTIFACT_DIR optional screenshot dir (default: system temp) +// Otherwise it prints one explicit SKIP line and the test still passes. + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { Database } from "bun:sqlite"; + +import { createPetAdapter } from "../lib/pet.adapter.mjs"; +import { createPetServer } from "../lib/pet.server.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +let PAGE_PORT = 0; +let authToken = ""; + +let failures = 0; +async function test(name, fn) { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + failures += 1; + console.error(`FAIL ${name}`); + console.error(error); + } +} + +const tempPaths = []; +function trackTemp(filePath) { + tempPaths.push(filePath); + return filePath; +} +function cleanup() { + for (const filePath of tempPaths) { + for (const suffix of ["", "-shm", "-wal"]) { + try { + rmSync(filePath + suffix, { force: true }); + } catch { + // best effort cleanup + } + } + } +} +process.on("exit", cleanup); + +// Build a fresh fixture database in a temp dir; tests must not depend on the +// author's local save. +async function freshPartnerDb() { + const target = trackTemp(join(tmpdir(), `pet-e2e-${randomUUID()}.db`)); + const adapter = createPetAdapter({ databasePath: target, notifyGate: () => false }); + await adapter.runCommand("digital-pet-spawn"); + return target; +} + +// node:http request that never touches HTTP_PROXY. +function httpJson(port, path, { method = "GET", body } = {}) { + const tokenHeaders = path.startsWith("/api/") && path !== "/api/ping" ? { "x-magpie-pet-token": authToken } : {}; + return new Promise((resolvePromise, reject) => { + const req = httpRequest( + { + host: "127.0.0.1", + port, + path, + method, + timeout: 5000, + headers: body === undefined ? { ...tokenHeaders } : { "content-type": "application/json", ...tokenHeaders }, + }, + (res) => { + let text = ""; + res.on("data", (chunk) => (text += chunk)); + res.on("end", () => { + let json = null; + try { + json = JSON.parse(text); + } catch { + // leave json null for non-JSON bodies + } + resolvePromise({ status: res.statusCode, headers: res.headers, text, json }); + }); + }, + ); + req.on("timeout", () => req.destroy(new Error("request timeout"))); + req.on("error", reject); + if (body !== undefined) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function withRawDb(databasePath, fn) { + const db = new Database(databasePath); + db.run("PRAGMA busy_timeout = 5000"); + try { + return fn(db); + } finally { + db.close(); + } +} + +function readBaseline(databasePath) { + return withRawDb(databasePath, (db) => ({ + receipts: db.query("SELECT COUNT(*) c, SUM(token_delta) t FROM usage_receipts").get(), + partner: db + .query( + "SELECT partner_id, generation, current_node_id FROM partners WHERE partner_id = (SELECT principal_id FROM partner_team WHERE team_id = 1) AND retired_at IS NULL", + ) + .get(), + partnerEvents: db.query("SELECT COUNT(*) c FROM partner_events").get().c, + })); +} + +// Deterministic starting point for the care loop: no cooldowns, mid-range +// stats so snack/clean effects are observable and not clamped at 100. +function resetCareFor(partnerId, databasePath) { + withRawDb(databasePath, (db) => { + // The read path no longer creates the row; make the fixture explicit. + db.query( + `INSERT OR IGNORE INTO magpie_pet_care_state + (partner_id, satiety, mood, energy, hygiene, bond, last_decay_at, updated_at) + VALUES (?, 72, 70, 80, 75, 0, ?, ?)`, + ).run(partnerId, Date.now(), Date.now()); + db.query( + `UPDATE magpie_pet_care_state SET + satiety = 50, mood = 60, energy = 80, hygiene = 40, bond = 5, + cooldown_snack = 0, cooldown_play = 0, cooldown_clean = 0, + cooldown_rest = 0, cooldown_pet_egg = 0 + WHERE partner_id = ?`, + ).run(partnerId); + db.query("DELETE FROM magpie_pet_expeditions WHERE partner_id = ?").run(partnerId); + db.query("DELETE FROM magpie_pet_care_events WHERE partner_id = ?").run(partnerId); + db.exec("DELETE FROM magpie_pet_achievements"); + db.exec("DELETE FROM magpie_pet_daily"); + db.exec("DELETE FROM magpie_pet_inventory"); + }); +} + +const post = (port, name, args) => + httpJson(port, "/api/command", { method: "POST", body: args === undefined ? { name } : { name, args } }); + +// =================================================================== core == +const dbFile = await freshPartnerDb(); +const baseline = readBaseline(dbFile); +assert.ok(baseline.partner, "fixture database must carry an active partner"); + +const server = createPetServer({ databasePath: dbFile, port: 0, notifyGate: () => false }); +assert.equal(await server.start(), true); +authToken = server.apiToken; + +await test("state on the fixture exposes partner, care, collection and empty frames", async () => { + const res = await httpJson(server.port, "/api/state"); + assert.equal(res.status, 200); + const state = res.json; + assert.equal(state.ok, true); + + // upstream history identical to the copy baseline + assert.equal(state.partner.id, baseline.partner.partner_id); + assert.equal(state.partner.generation, baseline.partner.generation); + assert.equal(state.partner.node.id, baseline.partner.current_node_id); + assert.equal(state.fedRequests, baseline.receipts.c); + assert.equal(state.fedTokens, baseline.receipts.t ?? 0); + assert.equal(state.events.length, Math.min(12, baseline.partnerEvents)); + + // care layer + assert.equal(state.care.state.partnerId, baseline.partner.partner_id); + assert.equal(typeof state.care.summary.moodLabel, "string"); + assert.equal(typeof state.daily.date, "string"); + assert.equal(state.daily.goals.length, 3); + assert.ok(Number.isFinite(state.collection.fragments)); + assert.ok(Array.isArray(state.collection.achievements)); + + // pixel frames: either real frames for this sprite or the stable {} fallback + assert.equal(typeof state.frames, "object"); + if (state.frame) { + assert.ok(state.frame.content.length > 0); + assert.ok(state.frame.available.includes(state.frame.name)); + } else { + assert.deepEqual(state.frames, {}); + } +}); + +await test("snack and clean mutate care state and record events/achievements", async () => { + resetCareFor(baseline.partner.partner_id, dbFile); + + const before = (await httpJson(server.port, "/api/state")).json; + const fragmentsBefore = before.collection.fragments; + + const snack = await post(server.port, "pet-feed-snack"); + assert.equal(snack.status, 200); + assert.ok(snack.json.text.length > 0); + // 50 + 10, minus a few milliseconds of decay before the snapshot + assert.ok(snack.json.state.care.state.satiety > 59.9 && snack.json.state.care.state.satiety <= 60); + + const clean = await post(server.port, "pet-clean"); + assert.equal(clean.status, 200); + assert.ok(clean.json.state.care.state.hygiene > 99.9); + // hygiene was 40 < 50: clean grants the +1 bond bonus + assert.equal(clean.json.state.care.state.bond, 6); + + // achievements unlock exactly through the public API + assert.ok(clean.json.state.collection.achievements.includes("first-care")); + assert.ok(clean.json.state.collection.achievements.includes("first-clean")); + + // the care daily goal (2 actions today) is done and paid its fragments + const careGoal = clean.json.state.daily.goals.find((g) => g.id === "care"); + assert.equal(careGoal.done, true); + assert.ok(clean.json.state.collection.fragments > fragmentsBefore); + + // original feed history untouched by care actions + assert.equal(clean.json.state.fedRequests, baseline.receipts.c); + assert.equal(clean.json.state.fedTokens, baseline.receipts.t ?? 0); +}); + +await test("patrol expedition starts and claims through /api/command, with history preserved", async () => { + const fragmentsBefore = (await httpJson(server.port, "/api/state")).json.collection.fragments; + const bondBefore = (await httpJson(server.port, "/api/state")).json.care.state.bond; + + const start = await post(server.port, "pet-expedition-start", "patrol"); + assert.equal(start.status, 200); + const expedition = start.json.state.expedition; + assert.equal(expedition.kind, "patrol"); + assert.equal(expedition.status, "running"); + // starting the patrol costs 10 energy (80 -> 70 minus negligible decay) + assert.ok(start.json.state.care.state.energy < 70.1 && start.json.state.care.state.energy >= 69); + + // too early: the production 5-minute duration still applies + const early = await post(server.port, "pet-expedition-claim", expedition.expeditionId); + assert.equal(early.status, 400); + assert.equal(early.json.reason, "not-finished"); + + // test-only fast-forward inside the throwaway copy; production constants untouched + withRawDb(dbFile, (db) => { + db.query("UPDATE magpie_pet_expeditions SET ends_at = ? WHERE expedition_id = ?").run( + Date.now(), + expedition.expeditionId, + ); + }); + + const claim = await post(server.port, "pet-expedition-claim", expedition.expeditionId); + assert.equal(claim.status, 200); + assert.ok(claim.json.text.length > 0, "claim returns the expedition note"); + + const after = claim.json.state; + assert.equal(after.expedition, null, "active expedition cleared after claim"); + assert.ok(after.collection.fragments >= fragmentsBefore + 3, "patrol pays 3 fragments"); + assert.equal(after.care.state.bond, Math.min(100, bondBefore + 1)); + assert.ok(after.collection.achievements.includes("first-expedition")); + const expeditionGoal = after.daily.goals.find((g) => g.id === "expedition"); + assert.equal(expeditionGoal.done, true); + + // care/expedition events landed in the copy + const eventKinds = withRawDb(dbFile, (db) => + db + .query("SELECT kind, COUNT(*) c FROM magpie_pet_care_events GROUP BY kind") + .all() + .map((row) => row.kind), + ); + for (const kind of ["care", "expedition-start", "expedition", "achievement"]) { + assert.ok(eventKinds.includes(kind), `missing ${kind} events`); + } + + // original upstream history still intact after the whole loop + assert.equal(after.fedRequests, baseline.receipts.c); + assert.equal(after.fedTokens, baseline.receipts.t ?? 0); + assert.equal(after.events.length, Math.min(12, baseline.partnerEvents)); +}); + +await test("close() afterwards refuses new connections (no proxy false-positives)", async () => { + await server.close(); + let refused = false; + try { + await httpJson(server.port, "/api/ping"); + } catch { + refused = true; + } + assert.equal(refused, true); +}); + +// ================================================================ browser == +const PW_PATH = process.env.PET_E2E_PLAYWRIGHT; +const EDGE_PATH = process.env.PET_E2E_EDGE; +const ARTIFACT_DIR = process.env.PET_E2E_ARTIFACT_DIR || join(tmpdir(), "magpie-pet-e2e-artifacts"); + +if (!PW_PATH || !EDGE_PATH) { + console.log( + "SKIP browser section: set PET_E2E_PLAYWRIGHT (playwright-core/index.mjs) and " + + "PET_E2E_EDGE (msedge.exe) to run the real Edge rendering acceptance", + ); +} else { + assert.ok(existsSync(resolve(PW_PATH)), `PET_E2E_PLAYWRIGHT not found: ${PW_PATH}`); + assert.ok(existsSync(resolve(EDGE_PATH)), `PET_E2E_EDGE not found: ${EDGE_PATH}`); + mkdirSync(ARTIFACT_DIR, { recursive: true }); + + const pw = await import(pathToFileURL(resolve(PW_PATH)).href); + const chromium = pw.chromium ?? pw.default?.chromium; + assert.ok(chromium, "playwright-core module must expose chromium"); + + // a fresh fixture for the page: known stats, no cooldowns, nothing in flight + const pageDb = await freshPartnerDb(); + const pageBaseline = readBaseline(pageDb); + const pageServer = createPetServer({ databasePath: pageDb, port: 0, notifyGate: () => false }); + assert.equal(await pageServer.start(), true); + PAGE_PORT = pageServer.port; + authToken = pageServer.apiToken; + // state read creates the care row; then pin deterministic stats + await httpJson(PAGE_PORT, "/api/state"); + resetCareFor(pageBaseline.partner.partner_id, pageDb); + + const browser = await chromium.launch({ executablePath: resolve(EDGE_PATH), headless: true }); + try { + for (const viewport of [ + { name: "light-1440", width: 1440, height: 900 }, + { name: "light-820", width: 820, height: 900 }, + ]) { + await test(`real page renders clean in light theme at ${viewport.width}px (${viewport.name})`, async () => { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + colorScheme: "light", + }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() !== "error") return; + // the browser's automatic /favicon.ico probe 404s; it is not an app error + if (msg.location()?.url?.endsWith("/favicon.ico")) return; + errors.push(`console: ${msg.text()}`); + }); + + await page.goto(`http://127.0.0.1:${PAGE_PORT}/?theme=light`, { waitUntil: "load" }); + for (const selector of ["#pet-canvas", "#care-bars", "#actions", "#daily-goals"]) { + await page.waitForSelector(selector, { state: "visible", timeout: 10000 }); + } + // wait until the first /api/state round-trip populated the dashboard + await page.waitForFunction( + () => document.querySelector("#pet-name")?.textContent !== "…", + { timeout: 10000 }, + ); + assert.equal(errors.length, 0, `page errors: ${errors.join(" | ")}`); + assert.equal(await page.evaluate(() => document.documentElement.classList.contains("theme-light")), true); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + assert.ok(overflow <= 1, `horizontal overflow of ${overflow}px at ${viewport.width}px`); + + const shot = join(ARTIFACT_DIR, `e2e-${viewport.name}.png`); + await page.screenshot({ path: shot, fullPage: true }); + console.log(` screenshot: ${shot}`); + await context.close(); + }); + } + + await test("clicking one available care button updates the page without errors", async () => { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() !== "error") return; + // the browser's automatic /favicon.ico probe 404s; it is not an app error + if (msg.location()?.url?.endsWith("/favicon.ico")) return; + errors.push(`console: ${msg.text()}`); + }); + await page.goto(`http://127.0.0.1:${PAGE_PORT}/?theme=light`, { waitUntil: "load" }); + await page.waitForFunction(() => document.querySelector("#pet-name")?.textContent !== "…", { timeout: 10000 }); + + // prefer clean; fall back to any other enabled care button + const preferred = ["pet-clean", "pet-feed-snack", "pet-rest", "pet-play", "pet-pet-egg"]; + let clicked = null; + for (const command of preferred) { + const button = page.locator(`#actions [data-command="${command}"]`); + if ((await button.count()) > 0 && (await button.isEnabled())) { + await button.click(); + clicked = command; + break; + } + } + if (clicked) { + // the command round-trip re-renders state; give it a moment + await page.waitForTimeout(800); + console.log(` clicked ${clicked}`); + } else { + console.log(" no enabled care button right now; verified rendering only"); + } + assert.equal(errors.length, 0, `page errors: ${errors.join(" | ")}`); + + const shot = join(ARTIFACT_DIR, "e2e-after-care-click.png"); + await page.screenshot({ path: shot, fullPage: true }); + console.log(` screenshot: ${shot}`); + await context.close(); + }); + } finally { + await browser.close(); + await pageServer.close(); + } +} + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log("\nall tests passed"); diff --git a/packages/magpie-digital-pet/tests/page-contract.test.mjs b/packages/magpie-digital-pet/tests/page-contract.test.mjs new file mode 100644 index 00000000..9d22cd48 --- /dev/null +++ b/packages/magpie-digital-pet/tests/page-contract.test.mjs @@ -0,0 +1,173 @@ +// tests/page-contract.test.mjs +// Static contract for the pet dashboard page (Task 6): app.html keeps only +// structure and static templates, app.js renders dynamic text via DOM APIs +// (never interpolating API fields into innerHTML), app.css ships both light +// and dark themes, and every command button is wired through data-command. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = dirname(HERE); + +const html = readFileSync(join(ROOT, "page", "app.html"), "utf8"); +const css = readFileSync(join(ROOT, "page", "app.css"), "utf8"); +const js = readFileSync(join(ROOT, "page", "app.js"), "utf8"); + +let failures = 0; +function test(name, fn) { + try { + fn(); + console.log(`PASS ${name}`); + } catch (error) { + failures += 1; + console.error(`FAIL ${name}`); + console.error(error.message); + } +} + +test("app.html contains no inline script logic", () => { + const inline = [...html.matchAll(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)] + .map((m) => m[1].trim()) + .filter((body) => body.length > 0); + assert.deepEqual(inline, [], "inline + + diff --git a/registry.json b/registry.json index 28c6a7e6..888c5b17 100644 --- a/registry.json +++ b/registry.json @@ -98,6 +98,8 @@ {"package": "@magpie-community/tools-layerfs", "name": "LayerFS", "kind": "tools", "community": true, "summary": {"en": "LayerFS workspaces for every agent: deduplicated checkpoints and branches behind stable filesystem paths, instead of a fresh copy of the tree for every agent.", "zh": "给每个 agent 的 LayerFS 工作区:稳定的文件系统路径背后是可去重的检查点与分支,而不是每个 agent 各复制一棵项目树。"}}, { "package": "@magpie-community/page-usage", "name": "Usage Glance", "kind": "page", "community": true, "summary": { "en": "A page in magpie's window with calls, errors, tokens and cost for today, 7 or 30 days, and the models behind them. Reads only your usage summary.", "zh": "在 magpie 窗口里一眼看到今天、7 天或 30 天的调用、错误、token、费用和对应模型。只读取用量汇总。" } }, + { "package": "@magpie-community/page-digital-pet", "name": "Digital Pet", "kind": "page", "community": true, + "summary": { "en": "A virtual pet fed by routed usage, with care actions, expeditions, daily goals, decorations, naming and a 650-entry dex. Runs a token-guarded local service on 127.0.0.1 and bundles no sprite art.", "zh": "由路由用量喂养的电子宠物:照顾、探险、每日目标、装饰、命名和 650 项图鉴。本地服务只监听 127.0.0.1 且带口令保护,不打包精灵图素材。" } }, { "package": "@magpie-community/page-chat", "name": "Chat", "kind": "page", "community": true, "summary": { "en": "A chat page in magpie's window by Magic_zero: talk to magpie's own models with your MCP servers, instructions and skills, images and LaTeX. Runs a local service on 127.0.0.1 that only its own page can use.", "zh": "Magic_zero 做的 magpie 窗口内「对话」页面:用 magpie 自己的模型聊天,带上你的 MCP 服务器、指令和技能,支持图片与 LaTeX。本地服务只在 127.0.0.1,只给自己的页面用。" } }, { "package": "@magpie-community/page-agent-office", "name": "Agent Office", "kind": "page", "community": true,