diff --git a/.development/README.md b/.development/README.md index 8e1c8c5..1650b41 100644 --- a/.development/README.md +++ b/.development/README.md @@ -12,3 +12,10 @@ Pet consumes `@oneworks/avatar-react` through `dependencies/avatar-react-a06ba84 This is a package built from merged source, **not** an npm registry publication of Avatar React rc.9. The file dependency and lockfile identify its actual artifact. The former `f1a366c` feature-branch tarball has been removed. Machine-readable provenance and upstream validation are in [SOURCE.json](SOURCE.json). The merged renderer includes native preset/breed palette and coat resolution, bounded scene caching and a renderer-only entry. Keep the precise source record when integrating this checkout. Publishing another Pet release remains a separate delivery step requiring compatible dependency revisions and full package verification. + +## Experimental shared economy + +The shared-economy dependency set and its build provenance are recorded in +[economy-dependencies.json](economy-dependencies.json). This separate experiment +does not change the merged Avatar dependency above. Runtime/native availability +and source/SDK test results are separate evidence. diff --git a/.development/dependencies/cordisx-be2403c.tgz b/.development/dependencies/cordisx-be2403c.tgz new file mode 100644 index 0000000..67e4ea5 Binary files /dev/null and b/.development/dependencies/cordisx-be2403c.tgz differ diff --git a/.development/dependencies/cordisx-economy-5d688fc.tgz b/.development/dependencies/cordisx-economy-5d688fc.tgz new file mode 100644 index 0000000..1669f1e Binary files /dev/null and b/.development/dependencies/cordisx-economy-5d688fc.tgz differ diff --git a/.development/dependencies/cordisx-protocol-465c444.tgz b/.development/dependencies/cordisx-protocol-465c444.tgz new file mode 100644 index 0000000..366e75f Binary files /dev/null and b/.development/dependencies/cordisx-protocol-465c444.tgz differ diff --git a/.development/economy-dependencies.json b/.development/economy-dependencies.json new file mode 100644 index 0000000..e320750 --- /dev/null +++ b/.development/economy-dependencies.json @@ -0,0 +1,41 @@ +{ + "status": "Experimental source/SDK integration; not merged, published, deployed, or native-accepted", + "petBase": "9689fdb34a4e46e2102249df18012338a5a6a7cf", + "artifacts": [ + { + "package": "cordisx", + "repository": "https://github.com/cordisx/cordisx", + "sourceCommit": "be2403c70664ff6624671224a405e409874d59c7", + "artifact": "dependencies/cordisx-be2403c.tgz", + "sha256": "abd300ccade96d095563f95f971164838237a376655a55b7f96bdccb3bf2dd46", + "sourceStatus": "experimental pushed provider branch; portable source SDK with complete Channel/CLIProxy bundles and shared Protocol module identity" + }, + { + "package": "@cordisx/protocol", + "repository": "https://github.com/cordisx/cordisx-protocol", + "sourceCommit": "465c444c65eec1be8e337b94c2cf658ed536f49c", + "artifact": "dependencies/cordisx-protocol-465c444.tgz", + "sha256": "9576e28592b44c589aa847f3e57c02db1731a664c5cfa5a0f1fd5c4b5a3e21c8", + "sourceStatus": "experimental pushed provider branch; exact source-built artifact" + }, + { + "package": "@cordisx/economy", + "repository": "https://github.com/cordisx/plugin-economy", + "sourceCommit": "5d688fc087f6dcad7a0ace5f90f328270078aa96", + "artifact": "dependencies/cordisx-economy-5d688fc.tgz", + "sha256": "2b932509322256e9296cf3dd1a19a9b3974d091c09f59e49604072236cbb206b", + "sourceStatus": "experimental pushed provider branch; exact source-built artifact" + } + ], + "rebuild": { + "economy": "checkout exact sourceCommit; npm ci --ignore-scripts; npm run build; npm pack --ignore-scripts", + "hostAndProtocol": "From exact Host be2403c70664ff6624671224a405e409874d59c7 checkout run node scripts/prepare-sdk.mjs /absolute/path/to/nonexistent-output; do not run npm ci first. Canonical recipe: https://github.com/cordisx/cordisx/blob/be2403c70664ff6624671224a405e409874d59c7/.agents/docs/sdk-source-packaging.md. Use packages/cordisx-0.1.0-beta.2.tgz and the Protocol package from the same output; verify sdk-evidence.json hashes. Provider full owner gate and native acceptance remain separate." + }, + "runtimeLimitation": "Prior Host 69b consumer Linux CI passed as recorded; the new be2403c pin requires its own Linux CI. Native host-isolated startup previously reached a login page, then CDP discovery timed out before Pet/Keychain interaction; native retries are paused. Generation reload requires reauthorization.", + "priorConsumerEvidence": { + "petCommit": "637efcdf1f487b58346e928395fe6c7ec96b449c", + "hostCommit": "69b0146c4d4b6acd411758ae4ec3005ea74d0b89", + "url": "https://github.com/cordisx/plugin-pet/actions/runs/34291582664", + "result": "pass: exact artifact hashes, normal npm ci, SDK dependency resolution, full typecheck/build/package checks and 171 tests" + } +} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..c4182b9 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,29 @@ +name: Pet checks +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pet-check-${{ github.head_ref || github.ref }} + cancel-in-progress: true +jobs: + linux: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24.14.1' + cache: npm + - name: Verify exact SDK artifact hashes + run: node --test test/economy-dependencies.mjs + - name: Install complete dependency graph + run: npm ci + - name: Verify SDK dependency resolution + run: npm ls cordisx @cordisx/protocol @cordisx/channel @cordisx/plugin-cli-proxy-api + - name: Check types, package and behavior + run: npm run check diff --git a/.gitignore b/.gitignore index 244d6ea..2e9c966 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ cordisx-package.json # Exact local Avatar candidate used by the development lockfile. !.development/dependencies/avatar-react-a06ba84-rc9.tgz + +# Exact experimental shared-economy consumer build inputs; see provenance record. +!.development/dependencies/cordisx-be2403c.tgz +!.development/dependencies/cordisx-protocol-465c444.tgz +!.development/dependencies/cordisx-economy-5d688fc.tgz diff --git a/README.md b/README.md index ea3b971..119747b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A small pet system living on Composer, with 32 native OneWorks Avatar characters Open Pet from a companion's context menu or the settings navigation. Use food, water and owned outfits directly from the selected pet's care panel. Skins unlock once and can be equipped independently on compatible companions. Consumables support quantity purchases; species unlocks and companion adoption are separate transactions. -Authorized local Token usage earns pet coins from newly observed, validated input and output totals. The first connection establishes a baseline; partial local coverage and unavailable states are shown explicitly, without creating a test balance. Pet coins do not consume model quota. See the [pet system guide](docs/pet-system.md) for care timing, recovery and implementation boundaries. +Pet can bind an economy instance and account shared with games. Purchases use durable server receipts and recoverable local delivery. Existing saves are backed up before a controlled one-time import; local balances are not trusted cloud assets. Future attributed v2 root work keeps the original 10,000-token reward rate through a separately authorized, prefunded and capped sponsor source. Profile aggregates and unavailable periods never accrue deferred rewards. See [shared economy and migration](docs/shared-economy.md) and the [pet system guide](docs/pet-system.md). ## Development @@ -25,7 +25,7 @@ npm run check npm run dev ``` -The `cordisx` development dependency is pinned to canonical merged Host `b75fa2c6f9563924feca271242e2709c136033a3`. This integration requires the Host revisions providing bounded Manager fill seats, verified local-development usage authorization and Composer menu v2. Protocol is merged at `04fb46ec758a89f58506fe5141f91a81d583f8e0`. Do not use the older release baseline below for this source tree. +This experimental economy branch uses exact source-built package artifacts recorded in [the economy dependency record](.development/economy-dependencies.json): Host `4fb60431564931441927c474dd35188f6596274b`, Protocol `8adc1aab908263e692bd56ca6165b9aeadabe4b9`, and Economy `80aa91c6168126ac2b164446cd365d2f7c6249a2`. The Host package includes public HTTP authorization; native App/Keychain interaction has not been verified. Unit/HTTP integration evidence is separate from native acceptance. Do not use the older release baseline below for this source tree. Avatar dependency provenance is recorded in [the source dependency record](.development/README.md), including the exact source commit and tarball checksum. A tarball built from merged source is not an npm publication; the package manifest and lockfile identify the actual artifact. Avatar React rc.9 is built from canonical merged commit `a06ba84c123cf82e2b1a59c36b403392e22f9d08`; the core is pinned to registry rc.9. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 52123bb..a0f035b 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -15,7 +15,7 @@ 从宠物右键菜单,或设置导航中的「宠物」进入。首页直接选择食物、饮水和已拥有的装扮;兼容宠物共享已解锁皮肤,但独立穿戴。消耗品支持批量购买,物种解锁和再次领养分别计费。 -授权后,新增且经过确认的本机输入与输出 Token 用量可以积累宠物币。首次连接只建立起点,部分覆盖与暂不可用状态会明确显示,不生成测试余额。宠物币不扣减模型额度。详细计时、恢复和开发边界见[宠物系统说明](docs/pet-system.md)。 +Pet 可绑定与游戏共用的经济实例和账户,购买使用服务端永久收据并支持本地恢复发货。旧存档先备份,再经受控的一次性迁移导入;本地余额不是可信云资产。未来可归因的 v2 正常根任务工作,通过独立授权、预注资且限额的赞助来源,保留每万 Token 一币的奖励;profile 汇总与不可用期间不积累待补收入。见[共享经济与迁移](docs/shared-economy.md)及[宠物系统说明](docs/pet-system.md)。 ## 开发 @@ -25,7 +25,7 @@ npm run check npm run dev ``` -开发依赖精确固定为正式合入的 Host `b75fa2c6f9563924feca271242e2709c136033a3`。本次集成需要提供 Manager 内容填充、本地开发用量权限和 Composer 菜单 v2 的 Host 修订。Protocol 已合入 `04fb46ec758a89f58506fe5141f91a81d583f8e0`。 下方旧安装包的基线不适用于当前源码。 +本共享经济实验分支的精确源码构建产物见[经济依赖记录](.development/economy-dependencies.json):Host `4fb60431564931441927c474dd35188f6596274b`、Protocol `8adc1aab908263e692bd56ca6165b9aeadabe4b9`、Economy `80aa91c6168126ac2b164446cd365d2f7c6249a2`。Host 包已包含公共 HTTP 授权;原生 App/Keychain 交互尚未验证。单元与 HTTP 集成测试和原生验收分别记录;下方旧安装包的基线不适用于当前源码。 Avatar 依赖的源提交与压缩包校验值见[源码依赖记录](.development/README.md)。从正式合入源码打包不等于 npm 发布,实际产物以包清单和锁文件为准;Avatar React rc.9 来自正式合并提交 `a06ba84c123cf82e2b1a59c36b403392e22f9d08`,核心精确固定为 registry rc.9。 diff --git a/docs/pet-system.md b/docs/pet-system.md index 49e2001..190d8c5 100644 --- a/docs/pet-system.md +++ b/docs/pet-system.md @@ -1,6 +1,6 @@ # 宠物系统说明 -此页描述正在开发的源码,不代表 v0.1.1 安装包已经包含这些功能。 +此页描述正在开发的源码,不代表 v0.1.1 安装包已经包含这些功能。共享经济的绑定、旧存档保护和购买恢复见[共享经济与迁移](shared-economy.md)。 ## 属性、状态与个体差异 @@ -38,17 +38,13 @@ 存档经公开 `ctx.documents` 保存,Host 按本机 profile、来源和插件身份隔离。换窗口、普通重载和同来源升级保留状态;不同来源或 profile 不自动继承,不宣称账号或跨设备同步。 -购买扣币、发货、喂食、安葬和复活均作为整份存档的原子事务提交。重复请求有幂等保护,多窗口冲突重试。损坏或未知版本存档拒绝覆盖,不以重置钱包的方式掩盖错误。经济记录保留,移动等高频操作只保存有限的近期记录。 +本地模式的购买扣币与发货,以及喂食、安葬和复活均作为整份存档的原子事务提交。共享模式的购买采用持久化意图、服务端订单和本地原子发货,详见共享经济指南。重复请求有幂等保护,多窗口冲突重试。损坏或未知版本存档拒绝覆盖,不以重置钱包的方式掩盖错误。经济记录保留,移动等高频操作只保存有限的近期记录。 ## 开发边界 Pet 负责目录、经济规则、照顾与动画;Avatar 负责模型、配色与花纹;Host 负责权限、原生操作、受控命中区域/菜单和持久化。插件不读取输入正文、原始鼠标事件、音频、原始会话文件或私有 Host 状态。 -Token 收入通过独立授权的公开 `ctx.usage` 接口获取。当前初始兑换比例是每 10,000 个新增有效 Token 获得 1 宠物币,余数保留;它不会消耗模型额度。第一次连接只建立基线,不把历史累计直接兑换。钱包与同一用量来源的结算进度一起保存,多窗口、重载和重复通知不能重复发币。 - -当前覆盖本机可确认的 Codex 输入加输出用量,缓存输入和推理输出已包含在其中,不再次相加;这不是账号账单或跨设备总量。新发现文件的已有内容、来源不明、数据不连续等无法确认的区间不会估算奖励。接口明确标记为部分覆盖,界面显示最近同步时间。暂时不可用时保留钱包和结算进度,恢复后从已确认的累计进度继续同步;欢迎物品和亲密度解锁仍可体验。 - -收益流水合并为累计记录,来源的结算进度永久保留。购买、喂食等经济记录仍分别保存;存档超过 Host 大小限制时明确拒绝新操作,不会默默丢弃收据或重置余额。 +当前公开 `ctx.usage` 的本机 profile 汇总不能区分 Agent 游戏推理,因此暂停这个来源的奖励,显示明确的归因不可用原因。不记录待补收入,不用前后差值补发。既有钱包、收入记录和用量游标仍完整保留,按[共享经济迁移政策](shared-economy.md)处理;正常照顾与已绑定钱包的消费不依赖使用奖励。 ## 宠物管理界面 @@ -92,7 +88,7 @@ Token 收入通过独立授权的公开 `ctx.usage` 接口获取。当前初始 同物种可以拥有多个个体,每只都有稳定的独立 ID、名字、性别、基础属性、性格、状态与已装备皮肤。新个体默认名字带序号,可独立改名和照顾;共享已解锁皮肤不意味着共享装备。性别在创建或旧存档迁移时按个体身份稳定初始化,重载不会重抽。旧个体 ID、名字、属性、钱包、背包和设备不变;其已有物种自动记为已解锁。 -领养与扣款在同一次存档事务中完成;并发领养生成不同 ID,同一操作重试不重复扣币或增加宠物。领养后可在伙伴管理中选择出场,仍受用户配置的同时出场数量限制。 +本地模式的领养与扣款在同一次存档事务中完成;共享模式先持久化领养意图,经服务端扣款收据确认后原子发放个体。同一操作重试不重复扣币或增加宠物。领养后可在伙伴管理中选择出场,仍受用户配置的同时出场数量限制。 ## 浏览与详情 diff --git a/docs/shared-economy.md b/docs/shared-economy.md new file mode 100644 index 0000000..abfe491 --- /dev/null +++ b/docs/shared-economy.md @@ -0,0 +1,120 @@ +# Pet 共享经济与旧存档迁移 + +开发中的实验集成;未执行真实用户迁移,也不代表已发布或原生应用验收。 +本页是 Pet 的迁移与恢复方案;经济服务的账户、商户和导入策略由 +[Economy](https://github.com/cordisx/plugin-economy) 拥有。 + +## 旧存档调查与保护边界 + +Pet 基线 `9689fdb34a4e46e2102249df18012338a5a6a7cf` 使用 Host owner document +`pet-system`。v1 保存宠物身份、性别、属性、性格、照顾及探索状态,物种解锁、 +已装备与已解锁皮肤、食物/道具库存、设备等级/储水/粮队列、设置、永久经济收据、 +近期操作收据与 usage 来源游标。 + +读取检查 `earned - spent = balance`,收入/支出与经济收据相等,usage 的 +`rewardedCoins` 合计与收入相等。早期 v1 缺失的养成字段按既有规则补全; +损坏、未知版本、不一致库存或账目均拒绝覆盖。读取会合并旧 usage 流水,所以 +迁移备份必须保存**原始文档**,不能仅保存规范化后的 PetState。 +这项调查针对代码和隔离样例,未读取用户真实存档。 + +## 迁移事务 + +1. 明确绑定 SDK 规范化后的服务地址、经济实例和账户;Host 仅在本次运行中提供不透明连接句柄。 + Pet 配置只保存服务地址、永久迁移来源及工作赞助来源,凭证内容和连接句柄不进入存档。 + 不同实例的资产不互换,已迁移文档不允许更换地址或账户绑定。 +2. 验证原始存档,并先以只创建的 CAS 写入独立、可恢复的本地备份文档。 + 校验备份摘要与原始内容一致,备份写入失败则不调用远端导入。 +3. 保存绑定、备份引用、摘要与一次性迁移标识,进入待导入状态。此时旧钱包仍是 + 旧资产记录;暂停会改动存档的操作,避免导入期间原始余额变化。 +4. 经济服务必须预先登记可信导入来源及本次快照批准记录。客户端仅引用批准记录; + 客户端声明的余额、收据和摘要本身不能证明它拥有可信云资产。无批准记录时保持 + 明确的待迁移状态;禁止将其改为任意 credit/grant 请求。 +5. 旧余额为零时,验证共享钱包身份并完成备份即可标记 `zero-balance`,不请求云端发币或导入。正余额的远端幂等导入返回的实例、账户与快照身份全部匹配后,原子切换为共享权威。 + 旧钱包与流水保留作历史审计,展示与购买采用共享钱包。存档版本升级,使旧版 + Pet 拒绝写入已迁移文档。 +6. 网络失败或本地提交失败保留待迁移标识,下次用同一标识恢复。远端来源唯一约束 + 必须阻止换请求 key、重新导入备份或重载增发。不能在成功导入后恢复旧钱包消费; + 回滚仅供诊断恢复宠物数据,资产必须先与原实例对账。 + +## 购买与恢复 + +购买、再次领养、设备升级都通过同一商户目录(完整 [229 项导出](../economy/pet-catalog.json))。客户端不能自报可信价格。 +先在本地校验发货条件与文档大小,再 CAS 保存购买意图和永久 key;远端调用开始前 +必须能从持久化意图重建完整发货。一个存档只允许一笔待完成购买,期间冻结本地 +变更,避免皮肤、设备、库存或宠物身份在发货前变化。照顾暂停,不积累离线饥饿债务。 + +经济服务原子扣款并创建永久物品收据;响应丢失时重放同一 key 返回同一收据。 +Pet 验证实例、账户、商品、数量、价格与 `fulfillmentTarget` 存档身份和意图完全一致,再将物品和已完成收据同时 +CAS 写入。CAS 冲突重读;本地不可用不删除远端收据或生成新 key。重载先对账原订单, +成功后才开放新购买。余额不足等拒绝也保留原意图,因为其他窗口可能仍在重试同一 key;入账后恢复原订单。当前服务没有持久取消订单 key 的终态接口,因此不提供可能遗失并发成功收据的本地取消。永久商品的其他 key 不得重复解锁;耗材的明确新购买才增加库存。 + +## 存档身份与设备边界 + +迁移时为这份逻辑存档创建持久随机 `storeId`。每个订单带 +`fulfillmentTarget: { namespace: 'pet', storeId }`,服务端永久保存并计入幂等请求。 +Pet 再从已授权账户读取订单,要求回执目标一致后才发货。全新安装生成不同 storeId, +不能将旧订单标为新存档已履约,也不会扫描账户历史订单自动补发商品。 + +同一 Host owner document 的多窗口与正常重载依靠 CAS 和永久本地履约收据防重。 +原逻辑存档恢复时须同时保留 v2 存档、storeId 和履约收据。**离线复制同一 storeId +形成两份独立存档的分叉,不保证跨设备全局唯一消耗**;Pet 库存和照顾仍由本地存档拥有, +首版没有服务端权威 Pet 库存与消费接口。不可把这项验证描述成跨设备 exactly-once。 + +## 配置与恢复操作 + +在 Host 渲染的插件设置中填写 `economyBaseUrl` 和管理员提供的永久 +`migrationSourceId`(默认 `pet-migration-v1`)。从 Pet 钱包点击“连接共享钱包”, +由 Host 对精确 origin 授权并在其遮罩输入中采集凭证;Pet 不接触凭证明文。 +首版 HTTP 连接按运行代次授权,重载后需重新授权**同一地址、实例和账户**。 +再点“同步钱包与恢复事务”继续原 key。无需恢复前端余额或复制 HTTP 句柄。 + +旧钱包有正余额时,点击“备份并申请迁移”会先写只创建备份和待迁移状态。 +管理员根据备份、原始摘要、账户及迁移政策登记同名 digest 的 entitlement 后,再同步即可。 +未经批准的客户端余额不会成为云端资产。目录由管理员离线导入,消费者请求携带 +`expectedTotal`,价格不一致会在扣款前拒绝;免费商品也生成零额永久订单。 + +存档被冻结期间不推进照顾,恢复时重新建立计时起点,不补扣暂停时间。 +普通网络故障没有待处理经济事务时仍可照顾现有宠物;共享余额未知时显示“待同步”, +不会显示为零或回退花费旧钱包。备份和旧流水不自动删除。 + +## 用量奖励 + +仅 `ctx.usage.readWork()` v2 的精确 `codex-local-work-input-output-v2` policy 与 +`host-game-cwd-v1` classification 可参与新奖励,要求游戏、fork/子任务、未知来源全部 excluded。 +它仍是本地 partial 统计,不能用作无限铸币证明;v1 profile 汇总不发币、不保存欠账。 + +管理员在经济服务为 Pet 单独创建有限预注资、总日限额和账户日限额的 reward source, +在插件配置填写 `workRewardSourceId`。用户先连接钱包并完成迁移,再点击 +“授权工作奖励赞助服务”,在 Host 遮罩输入中使用该 source 所属的专用 service 凭证。 +这是独立于用户钱包的 opaque 连接,凭证不交给游戏、模型或 Pet 文档。先通过只读 +`rewardSource` 核对实例、账户与来源;授奖始终附带 `expectedInstanceId`,用户凭证不能授奖。 + +`pet-work-rewards-v2` 是独立 CAS 记录;新授权、权限恢复和新 epoch 从当前值建立新基线, +不复用旧 usage watermark,不补发未配置或中断期间用量。连续有效增量沿用 +**10,000 Token = 1 宠物币**,同一连续 epoch 的不足一币余数保留。 +仅未来符合条件的根任务工作获得管理员有限赞助;不从游戏前后总量差推断工作。 + +每个增量区间生成包含账户/来源/epoch/起止水位的稳定摘要 eventId,同值作为请求 key。 +远端请求前持久化最多一条 pending。响应丢失、本地写失败、限额竞态及权限失效均保留 +原 body/event/key,恢复原事务;失败不是永久取消证明。pending 期间不叠加新奖励, +恢复后重新建立基线;查询时已知预算或 UTC 当日额度不足的区间直接停发、不留欠账。 +服务端同 source/event 永久去重,成功收据身份完全匹配才记录累计奖励;共享余额只读远端钱包。 +日志/文档不存 sponsor secret,重载仍需重新授权同一来源与账户。跨设备离线复制奖励日志 +不构成全局工作证明,运营者负责有限赞助的额度与凭证管理。 + +## 验证边界 + +隔离测试必须覆盖完整旧存档备份、损坏存档拒绝、重复导入、账户/实例不匹配、 +购买响应丢失、远端成功后本地失败、重载恢复、永久物品与耗材幂等,及真实 SDK 的 +游戏结算余额购买 Pet 物品。编译、完整 Pet owner checks 和包检查分别报告。 +原生 App 行为与生产迁移须有单独证据;不得以测试内存文档冒充真实存档验收。 + +## 实验 SDK 输入 + +精确依赖提交与产物 SHA-256 见 +[依赖来源记录](../.development/economy-dependencies.json)。Host 的便携 SDK 按 +[精确源码构建说明](https://github.com/cordisx/cordisx/blob/be2403c70664ff6624671224a405e409874d59c7/.agents/docs/sdk-source-packaging.md) +生成;在对应 Host checkout 直接运行 `node scripts/prepare-sdk.mjs` 并传入一个 +尚不存在的绝对输出目录,无需先安装 Host 依赖。消费者正常执行 `npm ci`,不使用 +`--ignore-scripts` 掩盖缺失的运行时依赖。Linux CI 先校验受版本控制的 tarball 哈希, +再安装并运行 Pet 完整检查;它不替代 Host owner gate 或原生 App 验收。 diff --git a/economy/pet-catalog.json b/economy/pet-catalog.json new file mode 100644 index 0000000..19b3d7d --- /dev/null +++ b/economy/pet-catalog.json @@ -0,0 +1,1376 @@ +[ + { + "id": "pet-cat", + "title": "猫猫", + "price": 0, + "namespace": "pet" + }, + { + "id": "pet-dog", + "title": "小狗", + "price": 150, + "namespace": "pet" + }, + { + "id": "pet-rabbit", + "title": "兔兔", + "price": 200, + "namespace": "pet" + }, + { + "id": "pet-alpaca", + "title": "羊驼", + "price": 400, + "namespace": "pet" + }, + { + "id": "pet-bear", + "title": "熊熊", + "price": 400, + "namespace": "pet" + }, + { + "id": "pet-beaver", + "title": "河狸", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-bun", + "title": "团子", + "price": 300, + "namespace": "pet" + }, + { + "id": "pet-capybara", + "title": "水豚", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-cloud", + "title": "云朵", + "price": 300, + "namespace": "pet" + }, + { + "id": "pet-cow", + "title": "奶牛", + "price": 400, + "namespace": "pet" + }, + { + "id": "pet-chick", + "title": "小鸡", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-chinchilla", + "title": "龙猫", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-deer", + "title": "小鹿", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-duck", + "title": "鸭鸭", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-ferret", + "title": "雪貂", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-fox", + "title": "狐狸", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-goose", + "title": "鹅鹅", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-hamster", + "title": "仓鼠", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-hedgehog", + "title": "刺猬", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-lion", + "title": "狮子", + "price": 400, + "namespace": "pet" + }, + { + "id": "pet-monkey", + "title": "猴子", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-otter", + "title": "水獭", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-pig", + "title": "小猪", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-guinea-pig", + "title": "豚鼠", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-owl", + "title": "猫头鹰", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-parrot", + "title": "鹦鹉", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-penguin", + "title": "企鹅", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-seal", + "title": "海豹", + "price": 400, + "namespace": "pet" + }, + { + "id": "pet-sheep", + "title": "绵羊", + "price": 280, + "namespace": "pet" + }, + { + "id": "pet-squirrel", + "title": "松鼠", + "price": 180, + "namespace": "pet" + }, + { + "id": "pet-sun", + "title": "小太阳", + "price": 300, + "namespace": "pet" + }, + { + "id": "pet-tiger", + "title": "老虎", + "price": 400, + "namespace": "pet" + }, + { + "id": "skin-white", + "title": "云朵白", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-orange", + "title": "橘子汽水", + "price": 60, + "namespace": "pet" + }, + { + "id": "skin-siamese", + "title": "奶咖", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-shiba", + "title": "柴犬", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-husky", + "title": "雪原", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-lop", + "title": "垂耳奶糖", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-dutch", + "title": "黑白布丁", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-friend", + "title": "相伴纪念", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-alpaca-cream-alpaca", + "title": "奶油羊驼", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-alpaca-caramel-alpaca", + "title": "焦糖羊驼", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-alpaca-gray-alpaca", + "title": "灰羊驼", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-alpaca-alpaca-cria", + "title": "羊驼宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-brown-bear", + "title": "棕熊", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-bear-polar-bear", + "title": "北极熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-asian-black-bear", + "title": "亚洲黑熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-giant-panda", + "title": "大熊猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-spectacled-bear", + "title": "眼镜熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-sun-bear", + "title": "马来熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-red-panda", + "title": "小熊猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-koala", + "title": "考拉", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-raccoon", + "title": "浣熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-wombat", + "title": "袋熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bear-teddy-bear", + "title": "泰迪熊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-beaver-north-american-beaver", + "title": "北美河狸", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-beaver-eurasian-beaver", + "title": "欧亚河狸", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-beaver-dark-beaver", + "title": "深色河狸", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-beaver-beaver-kit", + "title": "河狸宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bun-porcelain", + "title": "瓷白", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-bun-peach", + "title": "蜜桃", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-bun-cocoa", + "title": "可可", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-capybara-capybara", + "title": "原色水豚", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-capybara-sandy-capybara", + "title": "沙色水豚", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-capybara-dark-capybara", + "title": "深棕水豚", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-capybara-capybara-pup", + "title": "水豚宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-tabby", + "title": "狸花", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-gold", + "title": "金色", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-cocoa", + "title": "可可", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-russian-blue", + "title": "俄罗斯蓝猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-cow-cat", + "title": "奶牛猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cat-black-cat", + "title": "黑猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cloud-white", + "title": "纯白", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-cloud-sky", + "title": "晴空", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cloud-lilac", + "title": "浅丁香", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cow-dairy-cow", + "title": "黑白奶牛", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-cow-jersey-cow", + "title": "娟姗牛", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cow-highland-cow", + "title": "高地牛", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-cow-cow-calf", + "title": "小牛犊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chick-yellow-chick", + "title": "黄绒小鸡", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-chick-silkie-chick", + "title": "丝羽乌骨鸡", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chick-barred-rock-chick", + "title": "芦花鸡", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chick-buff-orpington-chick", + "title": "黄羽奥品顿鸡", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chinchilla-gray-chinchilla", + "title": "灰龙猫", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-chinchilla-beige-chinchilla", + "title": "米色龙猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chinchilla-white-chinchilla", + "title": "白龙猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-chinchilla-black-velvet-chinchilla", + "title": "黑丝绒龙猫", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-deer-sika-deer", + "title": "梅花鹿", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-deer-reindeer", + "title": "驯鹿", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-deer-white-deer", + "title": "白鹿", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-deer-deer-fawn", + "title": "小鹿宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-dog-corgi", + "title": "柯基", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-dog-golden-retriever", + "title": "金毛", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-dog-border-collie", + "title": "边境牧羊犬", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-dog-dalmatian", + "title": "大麦町", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-duck-mallard-duck", + "title": "绿头鸭", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-duck-pekin-duck", + "title": "北京鸭", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-duck-muscovy-duck", + "title": "番鸭", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-duck-yellow-duckling", + "title": "小黄鸭", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-ferret-sable-ferret", + "title": "黑貂色雪貂", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-ferret-albino-ferret", + "title": "白化雪貂", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-ferret-cinnamon-ferret", + "title": "肉桂雪貂", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-ferret-panda-ferret", + "title": "熊猫雪貂", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-fox-red-fox", + "title": "赤狐", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-fox-arctic-fox", + "title": "北极狐", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-fox-silver-fox", + "title": "银狐", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-fox-fennec-fox", + "title": "耳廓狐", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-goose-greylag-goose", + "title": "灰雁", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-goose-canada-goose", + "title": "加拿大黑雁", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-goose-snow-goose", + "title": "雪雁", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-goose-white-gosling", + "title": "小白鹅", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hamster-syrian-hamster", + "title": "金丝熊", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-hamster-pudding-hamster", + "title": "布丁仓鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hamster-silver-fox-hamster", + "title": "银狐仓鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hamster-sapphire-hamster", + "title": "蓝宝石仓鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hedgehog-european-hedgehog", + "title": "欧洲刺猬", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-hedgehog-cream-hedgehog", + "title": "奶油刺猬", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hedgehog-albino-hedgehog", + "title": "白化刺猬", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-hedgehog-cinnamon-hedgehog", + "title": "肉桂刺猬", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-lion-african-lion", + "title": "非洲狮", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-lion-lioness", + "title": "母狮", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-lion-white-lion", + "title": "白狮", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-lion-lion-cub", + "title": "小狮子", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-monkey-macaque", + "title": "猕猴", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-monkey-capuchin-monkey", + "title": "卷尾猴", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-monkey-golden-monkey", + "title": "金丝猴", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-monkey-baby-monkey", + "title": "猴宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-otter-sea-otter", + "title": "海獭", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-otter-river-otter", + "title": "河獭", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-otter-asian-small-clawed-otter", + "title": "亚洲小爪水獭", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-pig-pink-pig", + "title": "粉红小猪", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-pig-black-pig", + "title": "黑猪", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-pig-spotted-pig", + "title": "斑点猪", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-pig-wild-boar", + "title": "野猪", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-rabbit-netherland-dwarf", + "title": "荷兰侏儒兔", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-rabbit-himalayan-rabbit", + "title": "喜马拉雅兔", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-rabbit-lionhead-rabbit", + "title": "狮子兔", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-rabbit-english-spot", + "title": "英国斑点兔", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-guinea-pig-american-guinea-pig", + "title": "美国短毛豚鼠", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-guinea-pig-abyssinian-guinea-pig", + "title": "逆毛豚鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-guinea-pig-teddy-guinea-pig", + "title": "泰迪豚鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-guinea-pig-guinea-pig-pup", + "title": "豚鼠宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-owl-barn-owl", + "title": "仓鸮", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-owl-snowy-owl", + "title": "雪鸮", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-owl-great-horned-owl", + "title": "大角鸮", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-owl-little-owl", + "title": "纵纹腹小鸮", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-parrot-scarlet-macaw", + "title": "绯红金刚鹦鹉", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-parrot-blue-yellow-macaw", + "title": "蓝黄金刚鹦鹉", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-parrot-african-grey-parrot", + "title": "非洲灰鹦鹉", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-parrot-cockatiel", + "title": "玄凤鹦鹉", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-penguin-emperor-penguin", + "title": "帝企鹅", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-penguin-adelie-penguin", + "title": "阿德利企鹅", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-penguin-gentoo-penguin", + "title": "巴布亚企鹅", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-penguin-penguin-chick", + "title": "企鹅宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-seal-harbor-seal", + "title": "港海豹", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-seal-harp-seal", + "title": "竖琴海豹", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-seal-gray-seal", + "title": "灰海豹", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-seal-seal-pup", + "title": "海豹宝宝", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sheep-white-sheep", + "title": "白绵羊", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-sheep-black-faced-sheep", + "title": "黑脸羊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sheep-horned-ram", + "title": "卷角羊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sheep-lamb", + "title": "羊羔", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sheep-mountain-goat", + "title": "山羊", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-squirrel-red-squirrel", + "title": "红松鼠", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-squirrel-gray-squirrel", + "title": "灰松鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-squirrel-chipmunk", + "title": "花栗鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-squirrel-black-squirrel", + "title": "黑松鼠", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sun-solar", + "title": "暖阳", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-sun-gold", + "title": "金色", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-sun-ember", + "title": "余烬", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-tiger-bengal-tiger", + "title": "孟加拉虎", + "price": 0, + "namespace": "pet" + }, + { + "id": "skin-tiger-white-tiger", + "title": "白虎", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-tiger-golden-tiger", + "title": "金虎", + "price": 80, + "namespace": "pet" + }, + { + "id": "skin-tiger-tiger-cub", + "title": "小虎崽", + "price": 80, + "namespace": "pet" + }, + { + "id": "food-snack", + "title": "比特脆脆", + "price": 5, + "namespace": "pet" + }, + { + "id": "food-meal", + "title": "显存糯米团", + "price": 12, + "namespace": "pet" + }, + { + "id": "food-feast", + "title": "满血火锅", + "price": 25, + "namespace": "pet" + }, + { + "id": "food-fish", + "title": "向量小鱼干", + "price": 10, + "namespace": "pet" + }, + { + "id": "food-chicken", + "title": "张量鸡块", + "price": 15, + "namespace": "pet" + }, + { + "id": "food-carrot", + "title": "线程胡萝卜", + "price": 6, + "namespace": "pet" + }, + { + "id": "food-berry", + "title": "缓存莓果", + "price": 8, + "namespace": "pet" + }, + { + "id": "food-pudding", + "title": "梯度布丁", + "price": 18, + "namespace": "pet" + }, + { + "id": "food-apple", + "title": "红苹果", + "price": 5, + "namespace": "pet" + }, + { + "id": "food-corn", + "title": "玉米棒", + "price": 7, + "namespace": "pet" + }, + { + "id": "food-shrimp", + "title": "脆脆虾", + "price": 13, + "namespace": "pet" + }, + { + "id": "food-egg", + "title": "元气蛋", + "price": 8, + "namespace": "pet" + }, + { + "id": "food-croissant", + "title": "月牙可颂", + "price": 12, + "namespace": "pet" + }, + { + "id": "food-riceball", + "title": "饭团补给", + "price": 14, + "namespace": "pet" + }, + { + "id": "food-watermelon", + "title": "冰镇西瓜", + "price": 6, + "namespace": "pet" + }, + { + "id": "food-pancake", + "title": "松饼叠叠乐", + "price": 16, + "namespace": "pet" + }, + { + "id": "item-water-dispenser", + "title": "自动饮水机", + "price": 120, + "namespace": "pet" + }, + { + "id": "item-auto-feeder", + "title": "自动喂食器", + "price": 180, + "namespace": "pet" + }, + { + "id": "item-reboot-core", + "title": "复活图腾", + "price": 80, + "namespace": "pet" + }, + { + "id": "adopt-alpaca", + "title": "再次领养 alpaca", + "price": 200, + "namespace": "pet" + }, + { + "id": "adopt-bear", + "title": "再次领养 bear", + "price": 200, + "namespace": "pet" + }, + { + "id": "adopt-beaver", + "title": "再次领养 beaver", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-bun", + "title": "再次领养 bun", + "price": 150, + "namespace": "pet" + }, + { + "id": "adopt-capybara", + "title": "再次领养 capybara", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-cat", + "title": "再次领养 cat", + "price": 30, + "namespace": "pet" + }, + { + "id": "adopt-cloud", + "title": "再次领养 cloud", + "price": 150, + "namespace": "pet" + }, + { + "id": "adopt-cow", + "title": "再次领养 cow", + "price": 200, + "namespace": "pet" + }, + { + "id": "adopt-chick", + "title": "再次领养 chick", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-chinchilla", + "title": "再次领养 chinchilla", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-deer", + "title": "再次领养 deer", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-dog", + "title": "再次领养 dog", + "price": 75, + "namespace": "pet" + }, + { + "id": "adopt-duck", + "title": "再次领养 duck", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-ferret", + "title": "再次领养 ferret", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-fox", + "title": "再次领养 fox", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-goose", + "title": "再次领养 goose", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-hamster", + "title": "再次领养 hamster", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-hedgehog", + "title": "再次领养 hedgehog", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-lion", + "title": "再次领养 lion", + "price": 200, + "namespace": "pet" + }, + { + "id": "adopt-monkey", + "title": "再次领养 monkey", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-otter", + "title": "再次领养 otter", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-pig", + "title": "再次领养 pig", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-rabbit", + "title": "再次领养 rabbit", + "price": 100, + "namespace": "pet" + }, + { + "id": "adopt-guinea-pig", + "title": "再次领养 guinea-pig", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-owl", + "title": "再次领养 owl", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-parrot", + "title": "再次领养 parrot", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-penguin", + "title": "再次领养 penguin", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-seal", + "title": "再次领养 seal", + "price": 200, + "namespace": "pet" + }, + { + "id": "adopt-sheep", + "title": "再次领养 sheep", + "price": 140, + "namespace": "pet" + }, + { + "id": "adopt-squirrel", + "title": "再次领养 squirrel", + "price": 90, + "namespace": "pet" + }, + { + "id": "adopt-sun", + "title": "再次领养 sun", + "price": 150, + "namespace": "pet" + }, + { + "id": "adopt-tiger", + "title": "再次领养 tiger", + "price": 200, + "namespace": "pet" + }, + { + "id": "upgrade-water-2", + "title": "water Lv.2", + "price": 240, + "namespace": "pet" + }, + { + "id": "upgrade-water-3", + "title": "water Lv.3", + "price": 480, + "namespace": "pet" + }, + { + "id": "upgrade-feeder-2", + "title": "feeder Lv.2", + "price": 360, + "namespace": "pet" + }, + { + "id": "upgrade-feeder-3", + "title": "feeder Lv.3", + "price": 720, + "namespace": "pet" + } +] diff --git a/package-lock.json b/package-lock.json index e0c1371..01c6846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,21 @@ "version": "0.1.1", "license": "MIT", "dependencies": { + "@cordisx/economy": "file:.development/dependencies/cordisx-economy-5d688fc.tgz", "@oneworks/avatar": "1.0.0-rc.9", "@oneworks/avatar-react": "file:.development/dependencies/avatar-react-a06ba84-rc9.tgz" }, "devDependencies": { + "@cordisx/protocol": "file:.development/dependencies/cordisx-protocol-465c444.tgz", "@deepseek-ai/cordis": "4.0.1", - "cordisx": "github:cordisx/cordisx#b75fa2c6f9563924feca271242e2709c136033a3", + "@deepseek-ai/schemastery": "3.18.1", + "cordisx": "file:.development/dependencies/cordisx-be2403c.tgz", "esbuild": "0.28.2", "typescript": "^5.9.2", "vite": "8.2.2" }, "engines": { - "node": ">=22.19" + "node": ">=24.14" } }, "node_modules/@babel/runtime": { @@ -36,30 +39,19 @@ "node": ">=6.9.0" } }, - "node_modules/@cordisx/channel": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/cordisx/plugin-channel.git#4cee12e3a92eeed557bc9de8cc4792710918327a", - "integrity": "sha512-WqKvGxy72WwAUdwoLIHTBCG5/MAwVLKh2VmGK3tdSB+EMSUmuR+lAhxfdt9dYQRQL1Mh3t/w8SdYqps4mO0FkQ==", - "dev": true, - "license": "AGPL-3.0-or-later", - "engines": { - "node": ">=22.19" - } - }, - "node_modules/@cordisx/plugin-cli-proxy-api": { + "node_modules/@cordisx/economy": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/cordisx/plugin-cli-proxy-api.git#1428ee205aab31df2779398cc8491879b303d1d0", - "integrity": "sha512-EyIa/JSSHhbd9buCUd2gnI/Cdo47GYmJC+3FelfTNrA/krZSJN8TFE1c09yhySUtImtUliGv7ICa0XG+Ix/Kww==", - "dev": true, - "license": "AGPL-3.0-or-later", + "resolved": "file:.development/dependencies/cordisx-economy-5d688fc.tgz", + "integrity": "sha512-MlcqdP4O5M5xLdevpohNw2puzTGMpBHyuAtqIOn4kA11cNpkkWO1zbXFm9nQtjRi4MFOJAHA0GRbUPNRYwA6xw==", + "license": "MIT", "engines": { - "node": ">=22.19" + "node": ">=24.14" } }, "node_modules/@cordisx/protocol": { "version": "0.1.0-alpha.0", - "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#04fb46ec758a89f58506fe5141f91a81d583f8e0", - "integrity": "sha512-vBRRzhPhD2e6ROmo72XLNvdAjG7MbbT5wsC/HB5ZoA21mTcIx76Ty1/qk9qyLBnU9MdiWNOJPicct3q7LGF5ig==", + "resolved": "file:.development/dependencies/cordisx-protocol-465c444.tgz", + "integrity": "sha512-FQTx8M2a1gKqypSbc7QzBJe1qeqoySI5EuD+MqSvFMoTlGsmiJQSdG042njsggFtwnUqqD85uy0yIDrcqVXE8w==", "dev": true, "license": "AGPL-3.0-or-later", "engines": { @@ -108,13 +100,13 @@ "license": "MIT" }, "node_modules/@deepseek-ai/schemastery": { - "version": "3.18.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.2.tgz", - "integrity": "sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==", + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.1.tgz", + "integrity": "sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==", "dev": true, "license": "MIT", "dependencies": { - "@deepseek-ai/cosmokit": "^1.8.3", + "@deepseek-ai/cosmokit": "^1.8.2", "@standard-schema/spec": "^1.1.0" } }, @@ -1456,22 +1448,20 @@ } }, "node_modules/cordisx": { - "name": "cordisx-monorepo", "version": "0.1.0-beta.2", - "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#b75fa2c6f9563924feca271242e2709c136033a3", - "integrity": "sha512-dmhZGhZEpGSEkYgtR5bKPAda9mFZmGxCZqWmj6fJkEEhA3brZ+fcV/o1OJbRpmKTXW+/hzEtOTiT+l1CE7R9hQ==", + "resolved": "file:.development/dependencies/cordisx-be2403c.tgz", + "integrity": "sha512-tryCLoSmux6UHzLX7xsUmNqgs8TK1r7GI2S6bF4A2nFGpdy3tx1dHP7vyGHLs8Lk0SVetIt8Ad+TBu0RCNRc2A==", "bundleDependencies": [ - "@cordisx/schemastery-ui" + "@cordisx/schemastery-ui", + "@cordisx/channel", + "@cordisx/plugin-cli-proxy-api" ], "dev": true, "license": "AGPL-3.0-or-later", - "workspaces": [ - "packages/*" - ], "dependencies": { - "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", - "@cordisx/plugin-cli-proxy-api": "github:cordisx/plugin-cli-proxy-api#1428ee205aab31df2779398cc8491879b303d1d0", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#04fb46ec758a89f58506fe5141f91a81d583f8e0", + "@cordisx/channel": "0.1.0", + "@cordisx/plugin-cli-proxy-api": "0.1.0", + "@cordisx/protocol": "github:cordisx/cordisx-protocol#465c444c65eec1be8e337b94c2cf658ed536f49c", "@cordisx/schemastery-ui": "0.1.0-beta.2", "@deepseek-ai/cordis": "4.0.1", "@deepseek-ai/schemastery": "^3.18.1", @@ -1481,6 +1471,7 @@ "@shikitor/core": "1.0.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.0", "debug": "4.4.3", "es-module-lexer": "1.7.0", "esbuild": "0.28.2", @@ -1503,15 +1494,34 @@ "swr": "2.5.1", "tar": "7.5.22", "tdesign-react": "1.18.2", + "vite": "8.2.2", "ws": "^8.18.3" }, "bin": { - "cordisx": "packages/cli/dist/src/cli.js" + "cordisx": "dist/src/cli.js" }, "engines": { "node": ">=22.19" } }, + "node_modules/cordisx/node_modules/@cordisx/channel": { + "version": "0.1.0", + "dev": true, + "inBundle": true, + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=22.19" + } + }, + "node_modules/cordisx/node_modules/@cordisx/plugin-cli-proxy-api": { + "version": "0.1.0", + "dev": true, + "inBundle": true, + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=22.19" + } + }, "node_modules/cordisx/node_modules/@cordisx/schemastery-ui": { "version": "0.1.0-beta.2", "dev": true, @@ -1542,72 +1552,34 @@ "react-dom": ">=18.3.0 <20" } }, - "node_modules/cordisx/packages/cli": { - "name": "cordisx", - "version": "0.1.0-beta.2", - "bundleDependencies": [ - "@cordisx/schemastery-ui" - ], - "extraneous": true, - "license": "AGPL-3.0-or-later", + "node_modules/cordisx/node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", "dependencies": { - "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a", - "@cordisx/plugin-cli-proxy-api": "github:cordisx/plugin-cli-proxy-api#1428ee205aab31df2779398cc8491879b303d1d0", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#04fb46ec758a89f58506fe5141f91a81d583f8e0", - "@cordisx/schemastery-ui": "0.1.0-beta.2", - "@deepseek-ai/cordis": "4.0.1", - "@deepseek-ai/schemastery": "^3.18.1", - "@larksuiteoapi/node-sdk": "^1.73.0", - "@oneworks/avatar": "1.0.0-rc.8", - "@oneworks/avatar-react": "1.0.0-rc.8", - "@shikitor/core": "1.0.2", - "@types/react": "19.2.18", - "@types/react-dom": "19.2.5", - "@vitejs/plugin-react": "6.1.0", - "debug": "4.4.3", - "es-module-lexer": "1.7.0", - "esbuild": "0.28.2", - "extend": "3.0.2", - "intl-messageformat": "11.2.14", - "lightningcss": "1.33.0", - "luna-console": "1.3.6", - "luna-data-grid": "1.6.5", - "luna-dom-viewer": "1.8.4", - "luna-object-viewer": "0.3.2", - "react": "19.2.8", - "react-dom": "19.2.8", - "react-markdown": "10.1.0", - "rehype-raw": "^7.0.0", - "rehype-sanitize": "^6.0.0", - "reicon": "1.2.1", - "remark-gfm": "4.0.1", - "shiki": "4.4.3", - "style-to-js": "1.1.21", - "swr": "2.5.1", - "tar": "7.5.22", - "tdesign-react": "1.18.2", - "vite": "8.2.2", - "ws": "^8.18.3" + "@rolldown/pluginutils": "^1.0.1" }, - "bin": { - "cordisx": "dist/src/cli.js" + "engines": { + "node": "^20.19.0 || >=22.12.0" }, - "devDependencies": { - "@cordisx/channel-runtime": "file:../channel-runtime", - "@cordisx/protocol": "github:cordisx/cordisx-protocol#04fb46ec758a89f58506fe5141f91a81d583f8e0", - "@types/node": "^22.18.1", - "@types/react": "19.2.18", - "@types/react-dom": "19.2.5", - "@types/ws": "^8.18.1", - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsdom": "^26.1.0", - "tsx": "^4.20.5", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" }, - "engines": { - "node": ">=22.19" + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, "node_modules/cosmokit": { diff --git a/package.json b/package.json index 4639e9b..35c8c3c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "README.zh-Hans.md", "docs/pet-system.md", "cordisx-package.json", - "THIRD_PARTY_NOTICES.md" + "THIRD_PARTY_NOTICES.md", + "docs/shared-economy.md", + "economy/pet-catalog.json" ], "main": "./dist/runtime/module.js", "types": "./dist/types/plugin-composer-animal.d.ts", @@ -32,17 +34,23 @@ "prepack": "npm run build" }, "devDependencies": { + "@cordisx/protocol": "file:.development/dependencies/cordisx-protocol-465c444.tgz", "@deepseek-ai/cordis": "4.0.1", - "cordisx": "github:cordisx/cordisx#b75fa2c6f9563924feca271242e2709c136033a3", + "@deepseek-ai/schemastery": "3.18.1", + "cordisx": "file:.development/dependencies/cordisx-be2403c.tgz", "esbuild": "0.28.2", "typescript": "^5.9.2", "vite": "8.2.2" }, "engines": { - "node": ">=22.19" + "node": ">=24.14" }, "dependencies": { + "@cordisx/economy": "file:.development/dependencies/cordisx-economy-5d688fc.tgz", "@oneworks/avatar": "1.0.0-rc.9", "@oneworks/avatar-react": "file:.development/dependencies/avatar-react-a06ba84-rc9.tgz" + }, + "overrides": { + "@cordisx/protocol": "$@cordisx/protocol" } } diff --git a/src/pet-balance.ts b/src/pet-balance.ts new file mode 100644 index 0000000..5fef157 --- /dev/null +++ b/src/pet-balance.ts @@ -0,0 +1,12 @@ +import type { PetState } from './pet-domain.js' +import type { PetEconomyStatus } from './pet-economy.js' + +/** Unavailable shared funds are unknown, never a synthetic zero balance. */ +export function petVisibleBalance(state: PetState, economy?: PetEconomyStatus): number | null { + if (state.economy || economy?.binding) return economy?.status === 'ready' ? economy.wallet?.available ?? null : null + return state.wallet.balance +} +export function petCanAfford(state: PetState, cost: number, economy?: PetEconomyStatus): boolean { + const balance = petVisibleBalance(state, economy) + return balance !== null && balance >= cost +} diff --git a/src/pet-client.ts b/src/pet-client.ts index 2b4d37c..548f049 100644 --- a/src/pet-client.ts +++ b/src/pet-client.ts @@ -1,14 +1,24 @@ +import type { EconomyClient } from '@cordisx/economy/client' import type { Context } from '@deepseek-ai/cordis' import type { CordisXJsonValue, UsageV1 } from 'cordisx/contracts' import { migratePetState, type PetCommand, type PetState } from './pet-domain.js' -import { PetStore } from './pet-store.js' +import { type PetBackup, PetEconomyJournal } from './pet-economy-journal.js' +import { isPetPurchase, type PetEconomyBinding, petEconomyLocked } from './pet-economy-state.js' +import { PetEconomy, type PetEconomyStatus } from './pet-economy.js' +import { type PetStorageAdapter, PetStore } from './pet-store.js' import { PetUsageController, type PetUsageStatus } from './pet-usage.js' +import { type RewardIdentity, workRewardStorage } from './pet-work-reward-state.js' +import { PetWorkRewards, type WorkRewardStatus, type WorkSponsor } from './pet-work-rewards.js' export type PetClientSnapshot = { state: PetState | null error: string | null busy: boolean + workRewards?: WorkRewardStatus + canConnectSponsor?: boolean usage?: PetUsageStatus + economy?: PetEconomyStatus + canConnectEconomy?: boolean restingPetIds?: string[] feedback?: { id: string; sequence: number; kind: 'feed' | 'pet' | 'sleep' | 'wake' } } @@ -20,16 +30,29 @@ export type PetClientRuntime = { randomId: () => string } const defaultRuntime: PetClientRuntime = { - now: Date.now, monotonicNow: () => performance.now(), - setTimeout: globalThis.setTimeout.bind(globalThis), clearTimeout: globalThis.clearTimeout.bind(globalThis), + now: Date.now, + monotonicNow: () => performance.now(), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), randomId: () => crypto.randomUUID(), } +export type PetEconomyConnection = { + sdk: EconomyClient + binding: PetEconomyBinding + sourceId: string + dispose?: () => void +} const DOCUMENT = 'pet-system' const CONTRACT = 'cordisx.owner-documents/v1' as const /** One plugin-generation client; Host owns persistence, scope and cross-window notifications. */ export class PetClient { readonly #listeners = new Set<() => void>() readonly #store: PetStore + #economy: PetEconomy | undefined + readonly #journal: PetEconomyJournal + #disposeConnection: (() => void) | undefined + readonly #workRewards: PetWorkRewards + #rewardSourceId: string | undefined readonly #usage: PetUsageController | undefined #snapshot: PetClientSnapshot = { state: null, error: null, busy: false } #revision = -1 @@ -43,8 +66,21 @@ export class PetClient { #careReady = false #resting: string[] = [] readonly #unsubscribe: () => void - constructor(private readonly documents: Context['documents'], private readonly runtime: PetClientRuntime = defaultRuntime, usage?: UsageV1) { - this.#store = new PetStore({ + constructor( + private readonly documents: Context['documents'], + private readonly runtime: PetClientRuntime = defaultRuntime, + usage?: UsageV1, + economy?: PetEconomyConnection, + private readonly connector?: () => Promise, + private readonly sponsorConnector?: (identity: Omit) => Promise, + ) { + this.#workRewards = new PetWorkRewards(workRewardStorage(documents), status => { + this.update({ workRewards: status }) + if (status.status === 'ready') void this.refreshEconomy() + }) + this.#snapshot.canConnectSponsor = !!sponsorConnector + this.#snapshot.workRewards = { status: 'unavailable', reason: '工作奖励未配置赞助连接;期间用量不补发' } + const storage: PetStorageAdapter = { load: async () => { if (this.#closed) throw new Error('宠物服务已关闭') const result = await documents.load(DOCUMENT) @@ -57,16 +93,59 @@ export class PetClient { compareAndSwap: async (expectedRevision, state) => { if (this.#closed) throw new Error('宠物服务已关闭') const result = await documents.transaction({ - contract: CONTRACT, documentId: DOCUMENT, - expectedRevision: expectedRevision === null ? 0 : Number(expectedRevision), schemaVersion: 1, + contract: CONTRACT, + documentId: DOCUMENT, + expectedRevision: expectedRevision === null ? 0 : Number(expectedRevision), + schemaVersion: state.version, value: state as unknown as CordisXJsonValue, }) if (result.status === 'unavailable') throw new Error(result.diagnostic) if (result.status === 'accepted') this.accept(result.snapshot.revision, result.snapshot.value) return result.status === 'accepted' }, - }, { now: runtime.now, trustedUsageEnabled: usage !== undefined }) - this.#usage = usage ? new PetUsageController(usage, async (input, key, isCurrent) => { await this.#store.settleUsage(input, key, isCurrent) }, status => this.update({ usage: status })) : undefined + } + this.#store = new PetStore(storage, { now: runtime.now }) + this.#journal = new PetEconomyJournal(storage, { + create: async (id, backup) => { + if (this.#closed) throw new Error('宠物服务已关闭') + const existing = await documents.load(id) + if (existing.status === 'unavailable') throw new Error(existing.diagnostic) + if (existing.status === 'loaded') return + if (this.#closed) throw new Error('宠物服务已关闭') + const result = await documents.transaction({ + contract: CONTRACT, + documentId: id, + expectedRevision: 0, + schemaVersion: 1, + value: backup as unknown as CordisXJsonValue, + }) + if (result.status === 'unavailable') throw new Error(result.diagnostic) + }, + load: async id => { + if (this.#closed) throw new Error('宠物服务已关闭') + const result = await documents.load(id) + if (result.status !== 'loaded') throw new Error('原始备份不可用,迁移暂停') + return result.snapshot.value as unknown as PetBackup + }, + }, runtime.now) + if (economy) this.bindEconomy(economy) + this.#snapshot.canConnectEconomy = connector !== undefined + this.#snapshot.economy = { + status: 'unavailable', + reason: economy + ? '正在连接共享钱包' + : connector + ? '请连接共享钱包;重载后需要重新授权同一账户' + : '请在插件设置配置共享经济地址,并使用支持安全连接的 Host', + } + this.#usage = usage + ? new PetUsageController( + usage, + status => this.update({ usage: status }), + (snapshot, current) => this.#workRewards.observe(snapshot, this.rewardIdentity(), current), + () => this.#workRewards.pause('有效工作用量暂不可用;恢复后建立新基线,不补发期间用量'), + ) + : undefined this.#snapshot.usage = usage ? { status: 'initializing' } : { status: 'unavailable', reason: 'host-unavailable' } this.#unsubscribe = documents.subscribe(DOCUMENT, result => { if (result.status === 'loaded') this.accept(result.snapshot.revision, result.snapshot.value) @@ -74,10 +153,12 @@ export class PetClient { }) } getSnapshot = (): PetClientSnapshot => this.#snapshot - subscribe = (listener: () => void): (() => void) => { + subscribe = (listener: () => void): () => void => { if (this.#closed) return () => {} this.#listeners.add(listener) - return () => { this.#listeners.delete(listener) } + return () => { + this.#listeners.delete(listener) + } } private update(value: Partial): void { if (this.#closed) return @@ -93,17 +174,24 @@ export class PetClient { try { const state = migratePetState(value) this.#revision = revision - this.#resting = this.#resting.filter(id => state.activePetIds.includes(id) && state.pets.some(pet => pet.id === id && pet.status === 'alive')) + this.#resting = this.#resting.filter(id => + state.activePetIds.includes(id) && state.pets.some(pet => pet.id === id && pet.status === 'alive') + ) this.update({ state, error: null, restingPetIds: [...this.#resting] }) - } catch (error) { this.update({ error: error instanceof Error ? error.message : '宠物存档读取失败' }) } + } catch (error) { + this.update({ error: error instanceof Error ? error.message : '宠物存档读取失败' }) + } } async start(): Promise { if (this.#started || this.#closed) return this.#started = true - try { await this.initializeCare() } - catch (error) { this.update({ error: error instanceof Error ? error.message : '宠物数据暂不可用' }) } - finally { + try { + await this.initializeCare() + } catch (error) { + this.update({ error: error instanceof Error ? error.message : '宠物数据暂不可用' }) + } finally { this.scheduleCare() + if (!this.#closed) await this.refreshEconomy() if (!this.#closed) await this.#usage?.start() } } @@ -120,7 +208,7 @@ export class PetClient { if (initialized.status === 'loaded') this.accept(initialized.snapshot.revision, initialized.snapshot.value) else throw new Error(initialized.status === 'unavailable' ? initialized.diagnostic : '宠物数据暂不可用') } - if (!this.#closed && this.#snapshot.state) { + if (!this.#closed && this.#snapshot.state && !petEconomyLocked(this.#snapshot.state)) { await this.#store.execute({ type: 'carePulse', elapsedMs: 0 }, this.runtime.randomId()) this.#lastPulse = this.runtime.monotonicNow() this.#lastWallPulse = this.runtime.now() @@ -129,7 +217,8 @@ export class PetClient { } private canRest(id: string): boolean { const state = this.#snapshot.state - return !this.#closed && !!state?.activePetIds.includes(id) && state.pets.some(pet => pet.id === id && pet.status === 'alive') + return !this.#closed && !!state?.activePetIds.includes(id) + && state.pets.some(pet => pet.id === id && pet.status === 'alive') } requestSleep = (id: string): void => { if (this.canRest(id)) this.update({ feedback: { id, kind: 'sleep', sequence: ++this.#feedback } }) @@ -145,7 +234,9 @@ export class PetClient { this.#resting = next this.update({ restingPetIds: [...next] }) } - reportError = (message: string): void => { this.update({ error: message }) } + reportError = (message: string): void => { + this.update({ error: message }) + } refreshUsage = (): Promise => this.#closed ? Promise.resolve() : this.#usage?.refresh() ?? Promise.resolve() private scheduleCare(): void { if (this.#closed) return @@ -153,6 +244,7 @@ export class PetClient { this.#careTimer = undefined if (this.#closed) return try { + await this.refreshEconomy() if (!this.#careReady) await this.initializeCare() else { const now = this.runtime.monotonicNow() @@ -164,7 +256,11 @@ export class PetClient { // Some platforms pause performance.now during sleep. Either clock detecting // a suspension (or rollback) pauses care; neither may create offline debt. const paused = elapsed < 0 || wallElapsed < 0 || elapsed > 65_000 || wallElapsed > 65_000 - await this.#store.execute({ type: 'carePulse', elapsedMs: paused ? 0 : Math.min(elapsed, wallElapsed), restingPetIds: this.#resting }, this.runtime.randomId()) + await this.#store.execute({ + type: 'carePulse', + elapsedMs: paused ? 0 : Math.min(elapsed, wallElapsed), + restingPetIds: this.#resting, + }, this.runtime.randomId()) } } catch (error) { this.#careReady = false @@ -178,17 +274,113 @@ export class PetClient { this.#pending++ this.update({ busy: true, error: null }) try { - await this.#store.execute(command, this.runtime.randomId()) - if (command.type === 'feed' || command.type === 'water' || command.type === 'interact') this.update({ feedback: { id: command.petId, kind: command.type === 'interact' ? 'pet' : 'feed', sequence: ++this.#feedback } }) + if (isPetPurchase(command) && this.#economy) await this.#economy.purchase(command, this.runtime.randomId()) + else await this.#store.execute(command, this.runtime.randomId()) + if (command.type === 'feed' || command.type === 'water' || command.type === 'interact') { + this.update({ + feedback: { + id: command.petId, + kind: command.type === 'interact' ? 'pet' : 'feed', + sequence: ++this.#feedback, + }, + }) + } } catch (error) { this.update({ error: error instanceof Error ? error.message : '操作失败,请重试' }) - } finally { this.update({ busy: --this.#pending > 0 }) } + } finally { + this.update({ busy: --this.#pending > 0 }) + } + } + private bindEconomy(connection: PetEconomyConnection): void { + this.#economy?.dispose() + this.#disposeConnection?.() + this.#disposeConnection = connection.dispose + this.#economy = new PetEconomy( + connection.sdk, + this.#journal, + connection.binding, + connection.sourceId, + status => this.update({ economy: status }), + ) + } + private rewardIdentity(): RewardIdentity | undefined { + const state = this.#snapshot.state?.economy + if ( + !state || state.migration.status !== 'complete' || this.#snapshot.economy?.status !== 'ready' + || !this.#rewardSourceId + ) return undefined + return { baseUrl: state.baseUrl, ...state.binding, sourceId: this.#rewardSourceId } + } + connectSponsor = async (): Promise => { + if (this.#closed || !this.sponsorConnector || this.#pending) return + const state = this.#snapshot.state?.economy + if (!state || state.migration.status !== 'complete' || this.#snapshot.economy?.status !== 'ready') { + this.reportError('请先连接共享钱包并完成存档迁移') + return + } + this.#pending++ + this.update({ busy: true, error: null }) + try { + const sponsor = await this.sponsorConnector({ baseUrl: state.baseUrl, ...state.binding }) + if (this.#closed) { + sponsor.dispose() + return + } + this.#rewardSourceId = sponsor.identity.sourceId + this.#workRewards.connect(sponsor) + await this.refreshUsage() + } catch (error) { + this.reportError(error instanceof Error ? error.message : '工作赞助连接失败') + } finally { + this.update({ busy: --this.#pending > 0 }) + } + } + connectEconomy = async (): Promise => { + if (this.#closed || !this.connector || this.#pending) return + this.#pending++ + this.update({ busy: true, error: null }) + try { + const connection = await this.connector() + if (this.#closed) { + connection.dispose?.() + return + } + this.bindEconomy(connection) + await this.#economy!.refresh() + } catch (error) { + this.reportError(error instanceof Error ? error.message : '共享钱包连接失败') + } finally { + this.update({ busy: --this.#pending > 0 }) + } + } + refreshEconomy = async (): Promise => { + if (this.#closed || !this.#economy) return + try { + await this.#economy.refresh() + } catch (error) { + this.reportError(error instanceof Error ? error.message : '共享经济暂不可用') + } + } + migrateEconomy = async (): Promise => { + if (this.#closed || !this.#economy) return + this.#pending++ + this.update({ busy: true, error: null }) + try { + await this.#economy.migrate() + } catch (error) { + this.reportError(error instanceof Error ? error.message : '迁移待恢复') + } finally { + this.update({ busy: --this.#pending > 0 }) + } } dispose(): void { this.#closed = true if (this.#careTimer !== undefined) this.runtime.clearTimeout(this.#careTimer) this.#careTimer = undefined this.#usage?.dispose() + this.#workRewards.dispose() + this.#economy?.dispose() + this.#disposeConnection?.() this.#unsubscribe() this.#listeners.clear() } diff --git a/src/pet-device-controls.tsx b/src/pet-device-controls.tsx index 7122029..9e089ea 100644 --- a/src/pet-device-controls.tsx +++ b/src/pet-device-controls.tsx @@ -1,3 +1,5 @@ +import { petCanAfford } from './pet-balance.js' +import type { PetEconomyStatus } from './pet-economy.js' import { PetDeviceArt, PET_DEVICE_ART_STYLES } from './pet-device-art.js' import { useState } from 'cordisx/react' import { Button, Select } from 'cordisx/ui' @@ -8,7 +10,7 @@ import type { PetPageNavigation } from './pet-pages.js' import { PetFoodArt } from './pet-food-art.js' type DeviceProps = { - item: PetProduct; state: PetState; busy: boolean + item: PetProduct; state: PetState; busy: boolean; economy?: PetEconomyStatus run: (command: PetCommand) => void; navigation?: PetPageNavigation } function DeviceIcon({ kind }: { kind: 'shop' | 'power' | 'fill' | 'food' | 'upgrade' }) { @@ -23,7 +25,7 @@ function openDeviceProduct(navigation: PetPageNavigation | undefined, item: PetP navigation.session.hideOwned = false navigation.open('shop') } -export function DeviceControl({ item, state, busy, run, navigation }: DeviceProps) { +export function DeviceControl({ item, state, busy, run, navigation, economy }: DeviceProps) { const [foodId, setFoodId] = useState(state.devices.foodId) const [quantity, setQuantity] = useState(1) const [expanded, setExpanded] = useState(false) @@ -50,7 +52,7 @@ export function DeviceControl({ item, state, busy, run, navigation }: DeviceProp : <>
setQuantity(Math.max(1, Math.min(maximum, Math.trunc(event.currentTarget.valueAsNumber) || 1)))} />
{state.devices.foodQueue.length > 0 ?
    {state.devices.foodQueue.map((batch, index) => { const food = foods.find(food => food.id === batch.foodId); return
  1. {food?.name ?? batch.foodId} × {batch.quantity}营养 {food?.fullness ?? 0} · 精力 +{food?.energy ?? 0} · 心情 +{food?.mood ?? 0}
  2. })}
: 储粮仓为空 · 装入后才会自动喂食} 按装入顺序投放;实际饱食恢复随宠物体重和吸收能力变化。} - {capacity.tier < 3 ?
升级至 Lv.{capacity.tier + 1} · 容量 {nextCapacity} · 每轮 {nextService} 只
: Lv.3 · 已达最高级} + {capacity.tier < 3 ?
升级至 Lv.{capacity.tier + 1} · 容量 {nextCapacity} · 每轮 {nextService} 只
: Lv.3 · 已达最高级} } } diff --git a/src/pet-domain.ts b/src/pet-domain.ts index b45eecc..0fd3b41 100644 --- a/src/pet-domain.ts +++ b/src/pet-domain.ts @@ -1,3 +1,4 @@ +import { validatePetEconomy, type PetEconomyState } from './pet-economy-state.js' import { PET_SPECIES_IDS, type PetSpecies } from './pet-species.js' import { applyPetDevices, deviceCapacity, deviceFoodCount, DEFAULT_PET_DEVICES, PET_DEVICE_IDS, PET_DEVICE_UPGRADE_COST, type PetDeviceSettings } from './pet-devices.js' import { initialPetAttributes, foodEffect, petSeed, type PetAttributes } from './pet-attributes.js' @@ -31,7 +32,8 @@ export type PetSettings = { } export type PetReceipt = { key: string; kind: string; at: number; coins: number; detail: string } export type PetState = { - version: 1 + version: 1 | 2 + economy?: PetEconomyState pets: PetEntity[] unlockedSpecies: PetSpecies[] mainPetId: string @@ -142,7 +144,9 @@ export function migratePetState(raw: unknown): PetState { if (raw == null) return createPetState() assert(record(raw), '宠物存档无效') const state = structuredClone(raw) as PetState - assert(state.version === 1, '宠物存档版本不兼容,请升级插件') + assert(state.version === 1 || state.version === 2, '宠物存档版本不兼容,请升级插件') + if (state.version === 2) validatePetEconomy(state.economy) + else assert(state.economy === undefined, '共享经济存档版本无效') assert(state.settings === undefined || record(state.settings), '宠物设置无效') state.settings = { ...DEFAULT_PET_SETTINGS, ...state.settings } assert(validSettings(state.settings), '宠物设置无效') @@ -219,6 +223,10 @@ export function migratePetState(raw: unknown): PetState { && integer(item.totalTokens) && integer(item.rewardedCoins) && integer(item.remainderTokens) && item.remainderTokens < PET_ECONOMY.tokensPerCoin && (item.lastRevision === undefined || integer(item.lastRevision))), '用量结算记录无效') assert(Object.values(state.usage).reduce((total, item) => total + item.rewardedCoins, 0) === state.wallet.earned, '收入与用量结算记录不一致') + if (state.economy) { + assert(state.economy.migration.legacyBalance === state.wallet.balance, '迁移余额与保留的旧钱包不一致') + assert(state.economy.receipts.every(receipt => state.receipts.some(local => local.key === receipt.key && local.kind === receipt.command.type && local.coins === 0)), '共享履约收据与本地物品记录不一致') + } collapseUsageReceipts(state) return state } diff --git a/src/pet-economy-host.ts b/src/pet-economy-host.ts new file mode 100644 index 0000000..d77116f --- /dev/null +++ b/src/pet-economy-host.ts @@ -0,0 +1,78 @@ +import { EconomyClient, type Transport } from '@cordisx/economy/client' +import type { HttpClientV1, HttpConnectionV1, HttpRequestV1 } from '@cordisx/protocol/plugin-http/v1' +import type { PetEconomyConnection } from './pet-client.js' + +export type PetEconomyConfig = { economyBaseUrl?: string; migrationSourceId?: string; workRewardSourceId?: string } +export function petHostTransport(http: HttpClientV1, connection: HttpConnectionV1, lifetime: AbortSignal): Transport { + return async request => { + const url = new URL(request.url) + if (url.origin !== connection.origin || url.username || url.password || url.hash) { + throw new Error('经济请求超出已授权地址') + } + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) throw new Error('经济请求方法不受支持') + const headers: Record = {} + for (const [name, value] of Object.entries(request.headers)) { + const normalized = name.toLowerCase() + if (!['accept', 'content-type', 'idempotency-key'].includes(normalized)) { + throw new Error('经济请求包含不受支持的头部') + } + headers[normalized] = value + } + const result = await http.request({ + connection, + path: url.pathname + url.search, + method: request.method as HttpRequestV1['method'], + headers, + body: request.body, + deadline: Date.now() + 30000, + signal: request.signal ? AbortSignal.any([lifetime, request.signal]) : lifetime, + }) + if (result.status !== 'accepted') { + throw new Error(`共享经济连接不可用:${result.code};原事务保留,可重新授权后恢复`) + } + return { status: result.value.statusCode, body: result.value.body } + } +} +/** Called only from the wallet's connect action. Host captures the secret; Pet + * receives an ephemeral generation-bound handle and never persists it. */ +export function petEconomyConnector( + http: HttpClientV1 | undefined, + config: PetEconomyConfig, +): (() => Promise) | undefined { + if (!http || !config.economyBaseUrl?.trim()) return undefined + return async () => { + const baseUrl = config.economyBaseUrl!.trim() + const url = new URL(baseUrl) + // Validate the SDK URL and immutable migration-source shape before opening consent. + new EconomyClient(baseUrl, async () => { + throw new Error('not connected') + }) + const sourceId = config.migrationSourceId?.trim() || 'pet-migration-v1' + if (!/^[A-Za-z0-9._:-]{1,120}$/.test(sourceId)) throw new Error('迁移来源标识无效') + const result = await http.authorize({ origin: url.origin, credential: 'bearer' }) + if (result.status !== 'accepted') throw new Error(`共享钱包授权未完成:${result.code}`) + const connection = result.value + if ( + connection.contract !== 'cordisx.http-connection/v1' || connection.origin !== url.origin + || connection.credential !== 'bearer' + ) throw new Error('共享钱包授权范围不匹配') + const lifetime = new AbortController() + const sdk = new EconomyClient(baseUrl, petHostTransport(http, connection, lifetime.signal)) + try { + const wallet = await sdk.me() + return { + sdk, + binding: { instanceId: wallet.instanceId, accountId: wallet.accountId }, + sourceId, + dispose: () => { + lifetime.abort() + void http.revoke(connection) + }, + } + } catch (error) { + lifetime.abort() + await http.revoke(connection) + throw error + } + } +} diff --git a/src/pet-economy-journal.ts b/src/pet-economy-journal.ts new file mode 100644 index 0000000..ce9b65a --- /dev/null +++ b/src/pet-economy-journal.ts @@ -0,0 +1,162 @@ +import { migratePetState, type PetState } from './pet-domain.js' +import { + type PetEconomyBinding, + type PetPurchaseCommand, + type PetSharedReceipt, + samePetBinding, +} from './pet-economy-state.js' +import { grantPetPurchase, preparePetPurchase } from './pet-purchase.js' +import type { PetStorageAdapter } from './pet-store.js' + +export type PetBackup = { snapshotHash: string; value: unknown } +export type PetBackupAdapter = { + /** Create only; a pre-existing backup must never be overwritten. */ + create(id: string, backup: PetBackup): Promise + load(id: string): Promise +} +export async function petSnapshotHash(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return Array.from( + new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), + byte => byte.toString(16).padStart(2, '0'), + ).join('') +} +export function assertPetDocumentSize(value: unknown, reserve = 0): void { + if (new TextEncoder().encode(JSON.stringify(value)).byteLength + reserve > 512 * 1024) { + throw new Error('宠物存档空间不足,尚未调用经济服务;请保留备份') + } +} +/** This local journal never moves cloud money. Only the SDK coordinator can finish + * it after validating the authenticated service receipt against the frozen intent. */ +export class PetEconomyJournal { + constructor( + private readonly storage: PetStorageAdapter, + private readonly backups: PetBackupAdapter, + private readonly now = Date.now, + ) {} + async read(): Promise { + return migratePetState((await this.storage.load()).value) + } + async prepareMigration(binding: PetEconomyBinding, sourceId: string, baseUrl: string): Promise { + for (let attempt = 0; attempt < 12; attempt++) { + const snapshot = await this.storage.load() + const state = migratePetState(snapshot.value) + if (state.economy) { + this.assertBinding(state, binding) + if (state.economy.baseUrl !== baseUrl) throw new Error('经济服务地址与原存档不匹配') + if (state.economy.migration.sourceId !== sourceId) throw new Error('迁移来源已绑定,不能重复导入') + return state + } + const original = snapshot.value ?? state + const snapshotHash = await petSnapshotHash(original) + const backupDocumentId = `pet-backup-${snapshotHash}` + const backup = { snapshotHash, value: original } + assertPetDocumentSize(backup) + await this.backups.create(backupDocumentId, backup) + const stored = await this.backups.load(backupDocumentId) + if (stored.snapshotHash !== snapshotHash || await petSnapshotHash(stored.value) !== snapshotHash) { + throw new Error('迁移备份校验失败,原存档未切换') + } + state.version = 2 + state.economy = { + storeId: crypto.randomUUID(), + baseUrl, + binding: structuredClone(binding), + migration: { + status: 'pending', + key: `pet-import:${snapshotHash}`, + sourceId, + snapshotHash, + backupDocumentId, + legacyBalance: state.wallet.balance, + }, + receipts: [], + } + if (await this.save(snapshot.revision, state)) return state + } + throw new Error('宠物存档正在更新,尚未开始云端迁移') + } + async completeMigration(binding: PetEconomyBinding, key: string, receiptId: string): Promise { + return this.change(state => { + this.assertBinding(state, binding) + const migration = state.economy!.migration + if (migration.key !== key) throw new Error('迁移收据不匹配') + if (migration.status === 'complete' && migration.receiptId !== receiptId) throw new Error('重复迁移收据不一致') + migration.status = 'complete' + migration.receiptId = receiptId + return state + }) + } + async preparePurchase(binding: PetEconomyBinding, command: PetPurchaseCommand, key: string): Promise { + return this.change(state => { + this.assertBinding(state, binding) + const economy = state.economy! + if (economy.migration.status !== 'complete') throw new Error('旧钱包尚待受控迁移批准') + const prior = economy.receipts.find(item => item.key === key) + if (prior) { + if (JSON.stringify(prior.command) !== JSON.stringify(command)) throw new Error('购买标识已用于另一条命令') + return state + } + if (economy.pending) { + if (economy.pending.key !== key || JSON.stringify(economy.pending.command) !== JSON.stringify(command)) { + throw new Error('上一笔购买待对账,请先恢复原订单') + } + return state + } + const intent = preparePetPurchase(state, command, key, this.now()) + const delivered = grantPetPurchase(state, intent).state + delivered.economy!.receipts.push({ + key, + command: structuredClone(command), + orderId: 'x'.repeat(200), + itemId: intent.itemId, + quantity: intent.quantity, + total: intent.total, + at: intent.at, + }) + // Reserve enough space for the largest valid receipt and actual delivered state. + assertPetDocumentSize(delivered, 2048) + economy.pending = intent + return state + }) + } + async completePurchase(binding: PetEconomyBinding, receipt: PetSharedReceipt): Promise { + return this.change(state => { + this.assertBinding(state, binding) + const economy = state.economy! + const prior = economy.receipts.find(item => item.key === receipt.key) + if (prior) { + if (JSON.stringify(prior) !== JSON.stringify(receipt)) throw new Error('订单重放收据不一致') + return state + } + const pending = economy.pending + if ( + !pending || pending.key !== receipt.key || pending.itemId !== receipt.itemId + || pending.quantity !== receipt.quantity || pending.total !== receipt.total || pending.at !== receipt.at + || JSON.stringify(pending.command) !== JSON.stringify(receipt.command) + ) throw new Error('商品收据与待发货意图不一致') + const delivered = grantPetPurchase(state, pending).state + delete delivered.economy!.pending + delivered.economy!.receipts.push(structuredClone(receipt)) + return delivered + }) + } + private assertBinding(state: PetState, binding: PetEconomyBinding): void { + if (!state.economy || !samePetBinding(state.economy.binding, binding)) { + throw new Error('经济实例或账户不匹配,禁止切换或回退本地钱包') + } + } + private async save(revision: string | null, state: PetState): Promise { + migratePetState(state) + assertPetDocumentSize(state) + return this.storage.compareAndSwap(revision, state) + } + private async change(update: (state: PetState) => PetState): Promise { + for (let attempt = 0; attempt < 12; attempt++) { + const snapshot = await this.storage.load() + const state = update(migratePetState(snapshot.value)) + if (await this.save(snapshot.revision, state)) return state + } + throw new Error('其他窗口正在对账,请稍后重试原订单') + } +} diff --git a/src/pet-economy-state.ts b/src/pet-economy-state.ts new file mode 100644 index 0000000..69962e4 --- /dev/null +++ b/src/pet-economy-state.ts @@ -0,0 +1,106 @@ +import type { PetCommand, PetState } from './pet-domain.js' + +export type PetPurchaseCommand = Extract +export type PetEconomyBinding = { instanceId: string; accountId: string } +export type PetPurchaseIntent = { + key: string + command: PetPurchaseCommand + itemId: string + quantity: number + total: number + at: number +} +export type PetSharedReceipt = { + key: string + orderId: string + command: PetPurchaseCommand + itemId: string + quantity: number + total: number + at: number +} +export type PetEconomyState = { + storeId: string + baseUrl: string + binding: PetEconomyBinding + migration: { + status: 'pending' | 'complete' + key: string + sourceId: string + snapshotHash: string + backupDocumentId: string + legacyBalance: number + receiptId?: string + } + pending?: PetPurchaseIntent + receipts: PetSharedReceipt[] +} +export function samePetBinding(a: PetEconomyBinding, b: PetEconomyBinding): boolean { + return a.instanceId === b.instanceId && a.accountId === b.accountId +} +export function isPetPurchase(command: PetCommand): command is PetPurchaseCommand { + return ['buy', 'adopt', 'device-upgrade'].includes(command.type) +} +export function petEconomyLocked(state: PetState): boolean { + return !!state.economy && (state.economy.migration.status === 'pending' || !!state.economy.pending) +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} +function id(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 200 +} +function count(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} +function assert(value: unknown): asserts value { + if (!value) throw new Error('共享经济存档无效;保留原存档等待恢复') +} +export function validatePetEconomy(value: unknown): asserts value is PetEconomyState { + assert( + object(value) && object(value.binding) && typeof value.storeId === 'string' + && /^[a-f0-9-]{36}$/.test(value.storeId), + ) + assert(id(value.binding.instanceId) && id(value.binding.accountId)) + assert(typeof value.baseUrl === 'string' && value.baseUrl.length <= 2048) + const endpoint = new URL(value.baseUrl) + assert( + ['http:', 'https:'].includes(endpoint.protocol) && !endpoint.username && !endpoint.password && !endpoint.search + && !endpoint.hash, + ) + const migration = value.migration + assert(object(migration) && ['pending', 'complete'].includes(String(migration.status))) + assert(id(migration.key) && id(migration.sourceId) && id(migration.backupDocumentId)) + assert( + typeof migration.snapshotHash === 'string' && /^[a-f0-9]{64}$/.test(migration.snapshotHash) + && count(migration.legacyBalance), + ) + assert(migration.status !== 'complete' || id(migration.receiptId)) + assert(Array.isArray(value.receipts)) + for (const receipt of value.receipts) { + assert( + object(receipt) && object(receipt.command) + && ['buy', 'adopt', 'device-upgrade'].includes(String(receipt.command.type)) && id(receipt.key) + && id(receipt.orderId) && id(receipt.itemId), + ) + assert( + count(receipt.quantity) && receipt.quantity > 0 && receipt.quantity <= 99 && count(receipt.total) + && count(receipt.at), + ) + } + assert(new Set(value.receipts.map(receipt => receipt.key)).size === value.receipts.length) + assert(new Set(value.receipts.map(receipt => receipt.orderId)).size === value.receipts.length) + if (value.pending !== undefined) { + const pending = value.pending + assert(migration.status === 'complete' && object(pending) && object(pending.command)) + assert( + id(pending.key) && id(pending.itemId) && count(pending.quantity) && pending.quantity > 0 + && pending.quantity <= 99, + ) + assert( + count(pending.total) && count(pending.at) + && ['buy', 'adopt', 'device-upgrade'].includes(String(pending.command.type)), + ) + assert(!value.receipts.some(receipt => receipt.key === pending.key)) + } +} diff --git a/src/pet-economy-view.tsx b/src/pet-economy-view.tsx new file mode 100644 index 0000000..6f4aada --- /dev/null +++ b/src/pet-economy-view.tsx @@ -0,0 +1,68 @@ +import { Button, Stack, Text } from 'cordisx/ui' +import { petVisibleBalance } from './pet-balance.js' +import type { PetState } from './pet-domain.js' +import type { PetEconomyStatus } from './pet-economy.js' +import type { PetUsageStatus } from './pet-usage.js' +import type { WorkRewardStatus } from './pet-work-rewards.js' + +export function PetWallet({ state, economy, usage, migrate, refresh, connect, busy, workRewards, connectSponsor }: { + workRewards?: WorkRewardStatus + connectSponsor?: () => void + state: PetState + economy?: PetEconomyStatus + usage?: PetUsageStatus + busy?: boolean + migrate?: () => void + refresh?: () => void + connect?: () => void +}) { + const balance = petVisibleBalance(state, economy) + return ( + + {connect && ( + + )} + + {balance === null ? '余额待同步' : `${balance.toLocaleString()} 宠物币`} + + {(state.economy || economy?.binding) && ( + + 共享实例 {state.economy?.binding.instanceId ?? economy?.binding?.instanceId} · 账户{' '} + {state.economy?.binding.accountId ?? economy?.binding?.accountId} + + )} + {economy?.status === 'ready' && ( + 与游戏共用余额 · 冻结 {economy.wallet?.reserved ?? '待同步'} 币 + )} + {economy?.status === 'migration-required' && ( + <> + + 旧存档保留 {state.wallet.balance} 币。先备份,再由经济服务管理员按迁移政策批准;批准成功后启用共享购买。 + + + + )} + {state.economy?.migration.status === 'pending' && ( + 原始备份已保存,迁移待批准。宠物存档已保护,照顾计时暂停。 + )} + {state.economy?.migration.status === 'pending' && ( + 迁移申请摘要:{state.economy.migration.snapshotHash} + )} + {state.economy?.pending && 购买待对账;物品收据可恢复。请恢复原订单后再购买。} + {economy?.status === 'unavailable' && {economy.reason}} + {(state.economy || economy?.binding) && ( + + )} + {connectSponsor && } + {workRewards?.reason ?? '工作奖励未配置赞助连接;期间用量不补发'} + {workRewards?.earned !== undefined && 共享工作奖励累计 {workRewards.earned} 币} + + {usage?.status === 'ready' + ? '仅统计可归因的正常根任务工作;游戏、分叉、子任务和未知来源不计入。' + : usage?.status === 'unavailable' && usage.reason === 'permission-denied' + ? '使用奖励未开启。可在插件权限中管理。' + : '有效工作用量暂不可用;恢复后建立新基线,不补发期间用量。'} + + + ) +} diff --git a/src/pet-economy.ts b/src/pet-economy.ts new file mode 100644 index 0000000..6f835df --- /dev/null +++ b/src/pet-economy.ts @@ -0,0 +1,168 @@ +import { EconomyClient, type Wallet } from '@cordisx/economy/client' +import { PetEconomyJournal } from './pet-economy-journal.js' +import { type PetEconomyBinding, type PetPurchaseCommand, samePetBinding } from './pet-economy-state.js' + +export type PetEconomyStatus = { + status: 'unavailable' | 'migration-required' | 'migration-pending' | 'reconciling' | 'ready' + reason?: string + binding?: PetEconomyBinding + wallet?: Wallet +} +/** All server effects use the owner's SDK and its permanent idempotency contract. + * No local reward/credit API is exposed to Pet. */ +export class PetEconomy { + #closed = false + #tail: Promise = Promise.resolve() + constructor( + private readonly sdk: EconomyClient, + private readonly journal: PetEconomyJournal, + readonly binding: PetEconomyBinding, + readonly sourceId: string, + private readonly publish: (status: PetEconomyStatus) => void, + ) {} + refresh(): Promise { + return this.run(() => this.reconcile()) + } + migrate(): Promise { + return this.run(async () => { + await this.wallet() + this.active() + await this.journal.prepareMigration(this.binding, this.sourceId, this.sdk.baseUrl) + await this.reconcile() + }) + } + purchase(command: PetPurchaseCommand, key: string): Promise { + return this.run(async () => { + await this.wallet() + this.active() + const state = await this.journal.read() + if (state.economy && state.economy.baseUrl !== this.sdk.baseUrl) throw new Error('经济服务地址与原存档不匹配') + const pending = state.economy?.pending + if (pending && JSON.stringify(pending.command) !== JSON.stringify(command)) { + throw new Error('上一笔购买待对账,请先恢复原订单') + } + await this.journal.preparePurchase(this.binding, command, pending?.key ?? key) + await this.reconcile() + }) + } + private async reconcile(): Promise { + const wallet = await this.wallet() + this.active() + let state = await this.journal.read() + if (!state.economy) { + this.publish({ status: 'migration-required', binding: this.binding, wallet }) + return + } + if ( + state.economy.baseUrl !== this.sdk.baseUrl || !samePetBinding(state.economy.binding, this.binding) + || state.economy.migration.sourceId !== this.sourceId + ) { + throw new Error('经济实例、账户或迁移来源不匹配') + } + const migration = state.economy.migration + if (migration.status === 'pending' && migration.legacyBalance === 0) { + this.active() + state = await this.journal.completeMigration( + this.binding, + migration.key, + `zero-balance:${migration.snapshotHash}`, + ) + } else if (migration.status === 'pending') { + this.publish({ status: 'migration-pending', binding: this.binding, reason: '等待服务端批准原始备份的导入资格' }) + this.active() + const result = await this.sdk.claim( + { sourceId: migration.sourceId, entitlementId: migration.snapshotHash }, + migration.key, + ) + this.active() + if ( + !samePetBinding(result, this.binding) || result.sourceId !== migration.sourceId + || result.entitlementId !== migration.snapshotHash || result.amount !== migration.legacyBalance + ) throw new Error('迁移批准与原始备份不匹配,保留待对账状态') + await this.wallet() + this.active() + state = await this.journal.completeMigration(this.binding, migration.key, `migration:${migration.snapshotHash}`) + } + if (state.economy?.pending) { + const intent = state.economy.pending + this.publish({ status: 'reconciling', binding: this.binding, reason: '正在恢复原订单;请勿重新购买' }) + this.active() + // The owner catalogue is immutable. Compare before debit as well as on receipt. + const items = await this.sdk.items() + this.active() + const item = items.find(item => item.id === intent.itemId) + if (!item || item.namespace !== 'pet' || item.price * intent.quantity !== intent.total) { + throw new Error('服务端 Pet 商品目录与当前版本不兼容,原订单保留待对账') + } + // Even a pre-commit rejection must retain the journal: another window can + // still have the same key in flight. The service does not tombstone failures. + const order = await this.sdk.purchase({ + itemId: intent.itemId, + quantity: intent.quantity, + expectedTotal: intent.total, + fulfillmentTarget: { namespace: 'pet', storeId: state.economy.storeId }, + }, intent.key) + this.active() + // Retrieve the authenticated account's durable entitlement, not a caller receipt. + const confirmed = await this.sdk.order(order.id) + this.active() + if ( + !samePetBinding(order, this.binding) || !samePetBinding(confirmed, this.binding) + || confirmed.fulfillmentTarget?.namespace !== 'pet' + || confirmed.fulfillmentTarget?.storeId !== state.economy.storeId + || order.fulfillmentTarget?.storeId !== state.economy.storeId || confirmed.id !== order.id + || confirmed.itemId !== intent.itemId || confirmed.quantity !== intent.quantity + || confirmed.total !== intent.total + ) throw new Error('服务端物品收据与原订单不匹配') + await this.wallet() + this.active() + await this.journal.completePurchase(this.binding, { + key: intent.key, + command: intent.command, + orderId: confirmed.id, + itemId: confirmed.itemId, + quantity: confirmed.quantity, + total: confirmed.total, + at: intent.at, + }) + } + const latest = await this.wallet() + this.active() + this.publish({ status: 'ready', binding: this.binding, wallet: latest }) + } + private async wallet(): Promise { + this.active() + const wallet = await this.sdk.me() + this.active() + if ( + !samePetBinding(wallet, this.binding) || !Number.isSafeInteger(wallet.available) || wallet.available < 0 + || !Number.isSafeInteger(wallet.reserved) || wallet.reserved < 0 + ) throw new Error('共享钱包身份或余额无效') + return wallet + } + private active(): void { + if (this.#closed) throw new Error('经济连接已关闭,持久化事务将由下次连接恢复') + } + private run(work: () => Promise): Promise { + const next = this.#tail.then(async () => { + this.active() + try { + await work() + } catch (error) { + if (!this.#closed) { + this.publish({ + status: 'unavailable', + binding: this.binding, + reason: error instanceof Error ? error.message : '经济服务暂不可用;原事务保留', + }) + } + throw error + } + }) + this.#tail = next.catch(() => {}) + return next + } + dispose(): void { + this.#closed = true + } +} diff --git a/src/pet-pages.tsx b/src/pet-pages.tsx index f49f7f8..0c65c89 100644 --- a/src/pet-pages.tsx +++ b/src/pet-pages.tsx @@ -1,3 +1,8 @@ +import type { WorkRewardStatus } from './pet-work-rewards.js' +import { PetWallet } from './pet-economy-view.js' +import { petCanAfford, petVisibleBalance } from './pet-balance.js' +import type { PetEconomyStatus } from './pet-economy.js' +import { petEconomyLocked } from './pet-economy-state.js' import { usePetPreviewCache } from './pet-preview-cache.js' import { petNativeSnapshot } from './pet-native-snapshots.js' import { PET_SPECIES, PET_SPECIES_IDS, PET_SPECIES_GROUPS } from './pet-species.js' @@ -21,7 +26,7 @@ import { PetFoodArt } from './pet-food-art.js' export type PetPageSection = 'shop' | 'pets' | 'bag' | 'settings' | 'ledger' | 'pet-detail' | 'product-detail' | 'bag-detail' export type PetPageSession = { filter: string; hideOwned: boolean; selectedPet?: string; product?: string; anchor?: string; group?: string; species?: string; page?: number; detailTab?: 'status' | 'attributes' | 'actions'; carePanel?: 'food' | 'water' | 'skin' | 'play' | 'devices' } export type PetPageNavigation = { session: PetPageSession; open: (section: PetPageSection) => void } -type Commands = { state: PetState; busy: boolean; run: (command: PetCommand) => void; usage?: PetUsageStatus; navigation?: PetPageNavigation; sleep?: (id: string) => void; wake?: (id: string) => void; restingPetIds?: string[] } +type Commands = { state: PetState; busy: boolean; run: (command: PetCommand) => void; usage?: PetUsageStatus; economy?: PetEconomyStatus; migrate?: () => void; refresh?: () => void; connect?: () => void; navigation?: PetPageNavigation; sleep?: (id: string) => void; wake?: (id: string) => void; restingPetIds?: string[] } const Preview = memo(function Preview({ entity, skinId }: { entity: PetEntity; skinId?: string }) { return }, (a,b) => samePortrait(a.entity,b.entity) && a.skinId === b.skinId) @@ -50,17 +55,6 @@ function productEntity(product: PetProduct): PetEntity { const species = product.species ?? 'cat' return { sex: 'female', attributes: initialPetAttributes(product.id), exploration: { onlineMs: 0, eventIndex: 0, events: [] }, traits: initialPetTraits(product.id), id: `preview:${product.id}`, species, skinId: product.kind === 'skin' ? product.id : PET_DEFAULT_SKINS[species], name: product.name, affinity: 0, x: .5, status: 'alive', care: initialPetCare(species) } } -function Wallet({ state, usage }: { state: PetState; usage?: PetUsageStatus; navigation?: PetPageNavigation; sleep?: (id: string) => void }) { - return - {state.wallet.balance.toLocaleString()} 宠物币 - {usage?.status === 'ready' ? <> - 每新增 {PET_ECONOMY.tokensPerCoin.toLocaleString()} Token 获得 1 宠物币,不足部分会保留。 - 仅统计启用后本机可确认的使用量,不包含全部历史或其他设备。最近同步:{new Date(usage.observedThrough).toLocaleTimeString()}。 - : {usage?.status === 'initializing' ? '正在同步使用奖励…' - : usage?.status === 'unavailable' && usage.reason === 'permission-denied' ? '使用奖励未开启。可在插件权限中管理。' - : '使用奖励暂不可用,恢复后会继续同步。免费外观、欢迎点心和相伴解锁仍可体验。'}} - -} function ownedProduct(state: PetState, item: PetProduct) { return item.kind === 'pet' ? !!item.species && state.unlockedSpecies.includes(item.species) : item.kind === 'skin' ? state.ownedSkinIds.includes(item.id) : item.device ? (state.itemInventory[item.id] ?? 0) > 0 : false } @@ -68,16 +62,16 @@ function Glyph({ kind }: { kind: 'tree' | 'farm' | 'bird' | 'water' | 'smile' | const paths = { tree: 'm12 2-7 8h4l-5 7h6v5h4v-5h6l-5-7h4Z', farm: 'm3 10 9-7 9 7v11H3ZM8 21v-8h8v8m-8-8 8 8m0-8-8 8', bird: 'M4 20C4 8 11 2 20 3c1 9-5 15-16 17m0 0 11-11M8 16v-5m4 1h5', water: 'M12 2C8 8 4 11 4 15a8 8 0 0 0 16 0c0-4-4-7-8-13Z', smile: 'M21 12a9 9 0 1 0-18 0 9 9 0 0 0 18 0M8 9h.01M16 9h.01M7 14q5 7 10 0', brain: 'M12 21V3M12 5C3-4 0 13 7 13M7 13c-7 5 3 13 5 5M12 5c9-9 12 8 5 8m0 0c7 5-3 13-5 5', luck: 'm12 2 3 7 7 3-7 3-3 7-3-7-7-3 7-3Z', more: 'M4 12h.01M12 12h.01M20 12h.01', edit: 'm15 4 5 5M4 15l12-12 5 5L9 20l-6 1Z', back: 'm14 5-7 7 7 7M7 12h14', settings: 'M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1ZM15 12a3 3 0 1 0-6 0 3 3 0 0 0 6 0', shop: 'M3 10h18l-2-7H5ZM4 10v11h16V10M9 21v-7h6v7', bag: 'M4 6h16v15H4ZM8 6V4a4 4 0 0 1 8 0v2M8 10v2M16 10v2', moon: 'M20 15A9 9 0 0 1 9 3a9 9 0 1 0 11 12Z', paw: 'M8 14c-4 7 12 7 8 0l-4-4Z M5 6v2 M10 3v2 M15 3v2 M20 6v2', coin: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M15 8h-4a2 2 0 0 0 0 4h2a2 2 0 0 1 0 4H9 M12 6v12', food: 'M3 11h18c0 11-18 11-18 0Z M8 3v4 M13 2v5 M18 3v4', heart: 'M12 20 3 11C-2 2 10 1 12 7c2-6 14-5 9 4Z', energy: 'M14 2 5 14h7l-2 8 9-13h-7Z', outfit: 'M8 3 2 7l3 5 3-2v11h8V10l3 2 3-5-6-4c0 5-8 5-8 0Z', arrow: 'M8 4l8 8-8 8' } return } -function ProductActions({ item, state, busy, run, quantity = 1 }: Commands & { item: PetProduct; quantity?: number }) { +function ProductActions({ item, state, busy, run, economy, quantity = 1 }: Commands & { item: PetProduct; quantity?: number }) { const owned = ownedProduct(state, item) if (owned && item.kind === 'pet' && item.species) { const price = petAdoptionPrice(item.species) - return + return } if (owned) return null const applicable = item.kind !== 'skin' || state.pets.some(pet => pet.species === item.species && pet.affinity >= (item.requiredAffinity ?? 0)) const affinity = Math.max(0, ...state.pets.map(pet => pet.affinity)) - const reason = owned ? '已拥有' : !applicable ? '需要适用宠物及亲密度' : state.wallet.balance < item.price * quantity ? '宠物币不足' : '' + const reason = owned ? '已拥有' : !applicable ? '需要适用宠物及亲密度' : !petCanAfford(state, item.price * quantity, economy) ? '宠物币不足' : '' return {item.kind === 'pet' && item.requiredAffinity && !owned && } @@ -181,7 +175,7 @@ function Afterlife({ entity, state, busy, run }: Commands & { entity: PetEntity } function PetCard(props: Commands & { entity: PetEntity; selectPet: (id: string) => void }) { - const { entity, state, busy, run } = props + const { entity, state, busy, run, economy } = props const [tab, setTab] = useState(props.navigation?.session.detailTab ?? 'status') const [name, setName] = useState(entity.name) const [editing, setEditing] = useState(false) @@ -233,7 +227,7 @@ function FeedingTray({ entity, state, busy, run, navigation }: Commands & { enti } export function PetWardrobe(props: Commands & { entity: PetEntity }) { - const { entity, state, busy, run } = props + const { entity, state, busy, run, economy } = props const skins = PET_CATALOG.filter(item => item.kind === 'skin' && item.species === entity.species) const [selected, setSelected] = useState(entity.skinId) const skin = skins.find(item => item.id === selected) ?? skins[0] @@ -241,7 +235,7 @@ export function PetWardrobe(props: Commands & { entity: PetEntity }) { const owned = state.ownedSkinIds.includes(skin.id) const applicableAffinity = Math.max(0, ...state.pets.filter(pet => pet.species === entity.species).map(pet => pet.affinity)) const requiredAffinity = skin.requiredAffinity ?? 0 - const reason = applicableAffinity < requiredAffinity ? `需要亲密度 ${requiredAffinity} · 当前 ${applicableAffinity}` : state.wallet.balance < skin.price ? '宠物币不足' : '' + const reason = applicableAffinity < requiredAffinity ? `需要亲密度 ${requiredAffinity} · 当前 ${applicableAffinity}` : !petCanAfford(state, skin.price, economy) ? '宠物币不足' : '' return
{skins.map(item => { const unlocked = state.ownedSkinIds.includes(item.id) return @@ -360,21 +354,21 @@ function Settings({ state, busy, run }: Commands) { 减少动态效果会停用滚动、跳跃和大幅形变。离线时暂停饱食度、精力和健康变化;在线长期饥饿可能导致死亡。 } -function Ledger({ state, usage }: { state: PetState; usage?: PetUsageStatus; navigation?: PetPageNavigation; sleep?: (id: string) => void }) { - const records = state.receipts.filter(item => ['adopt', 'buy', 'claim', 'feed', 'water', 'forage', 'usage', 'usage-baseline', 'bury', 'revive'].includes(item.kind)).slice().reverse() +function Ledger({ state, usage, economy, migrate, refresh, connect, workRewards, connectSponsor }: { workRewards?: WorkRewardStatus; connectSponsor?: () => void; state: PetState; usage?: PetUsageStatus; economy?: PetEconomyStatus; migrate?: () => void; refresh?: () => void; connect?: () => void; navigation?: PetPageNavigation; sleep?: (id: string) => void }) { + const records = state.receipts.filter(item => ['adopt', 'buy', 'claim', 'feed', 'water', 'forage', 'usage', 'usage-baseline', 'bury', 'revive', 'device-upgrade'].includes(item.kind)).slice().reverse() return - - 累计获得 {state.wallet.earned} · 累计花费 {state.wallet.spent} + + {state.economy ? '旧钱包历史 · ' : ''}累计获得 {state.wallet.earned} · 累计花费 {state.wallet.spent} {!records.length ? : records.map(item => {item.detail}{new Date(item.at).toLocaleString()} - {item.coins > 0 ? '+' : ''}{item.coins} 宠物币 + {state.economy?.receipts.some(receipt => receipt.key === item.key) ? `−${state.economy.receipts.find(receipt => receipt.key === item.key)!.total}` : `${item.coins > 0 ? '+' : ''}${item.coins}`} 宠物币 )} {state.careHistory.filter(item => item.kind === 'death').slice().reverse().map(item => {state.pets.find(entity => entity.id === item.petId)?.name} · 已逝去 · {new Date(item.at).toLocaleString()})} } -function PetToolbar({ section, state, navigation, back }: { section: PetPageSection; state: PetState; navigation?: PetPageNavigation; back?: () => void }) { +function PetToolbar({ section, state, economy, navigation, back }: { section: PetPageSection; state: PetState; economy?: PetEconomyStatus; navigation?: PetPageNavigation; back?: () => void }) { const active = section === 'product-detail' ? 'shop' : section === 'bag-detail' ? 'bag' : section === 'pet-detail' ? 'pets' : section - return
{back && }
+ return
{back && }
} export function PetPage({ client, section: initialSection, navigation: routeNavigation }: { client: PetClient; section: PetPageSection; navigation?: PetPageNavigation; sleep?: (id: string) => void }) { const [section, setSection] = useState(initialSection) @@ -408,7 +402,7 @@ export function PetPage({ client, section: initialSection, navigation: routeNavi void client.execute(command).then(() => { const error = client.getSnapshot().error; setNotice(error ? { message: error, error: true, command } : { message: command.type === 'water' ? '喝过水啦' : command.type === 'interact' ? '陪伴已回应' : command.type === 'feed' ? '喂食成功' : command.type === 'equip' ? '已换上新装扮' : command.type === 'buy' || command.type === 'claim' ? '已放入背包或宠物列表' : '已保存' }) }).catch(error => setNotice({ message: error instanceof Error ? error.message : '操作失败,请重试', error: true, command })) } if (!snapshot.state) return - const props = { state: snapshot.state, busy: snapshot.busy, usage: snapshot.usage, run, navigation, sleep: client.requestSleep, wake: client.requestWake, restingPetIds: snapshot.restingPetIds } + const props = { workRewards: snapshot.workRewards, connectSponsor: snapshot.canConnectSponsor ? client.connectSponsor : undefined, state: snapshot.state, busy: snapshot.busy || petEconomyLocked(snapshot.state), usage: snapshot.usage, economy: snapshot.economy, migrate: client.migrateEconomy, refresh: client.refreshEconomy, connect: snapshot.canConnectEconomy ? client.connectEconomy : undefined, run, navigation, sleep: client.requestSleep, wake: client.requestWake, restingPetIds: snapshot.restingPetIds } return - navigation?.open(section === 'product-detail' || section === 'ledger' ? 'shop' : section === 'bag-detail' ? 'bag' : 'pets') : undefined} /> + navigation?.open(section === 'product-detail' || section === 'ledger' ? 'shop' : section === 'bag-detail' ? 'bag' : 'pets') : undefined} /> {snapshot.error && !notice && {snapshot.error}} {notice &&
{notice.error ? '!' : '✓'}{notice.message}{notice.error && notice.command && }
}
{section === 'shop' ? : section === 'pets' ? : section === 'bag' ? - : section === 'pet-detail' ? : section === 'product-detail' || section === 'bag-detail' ? : section === 'settings' ? : } + : section === 'pet-detail' ? : section === 'product-detail' || section === 'bag-detail' ? : section === 'settings' ? : }
} diff --git a/src/pet-purchase.ts b/src/pet-purchase.ts new file mode 100644 index 0000000..4155932 --- /dev/null +++ b/src/pet-purchase.ts @@ -0,0 +1,63 @@ +import { PET_CATALOG, petAdoptionPrice, petProduct } from './pet-catalog.js' +import { deviceCapacity, PET_DEVICE_UPGRADE_COST } from './pet-devices.js' +import { applyPetCommand, migratePetState, type PetState, type PetTransition } from './pet-domain.js' +import type { PetPurchaseCommand, PetPurchaseIntent } from './pet-economy-state.js' +import { PET_SPECIES_IDS } from './pet-species.js' + +/** Pet owns its catalogue. The service independently installs this versioned catalogue; + * the customer request never establishes a price. */ +export function petMerchantCatalog() { + return [ + ...PET_CATALOG.map(item => ({ id: item.id, title: item.name, price: item.price, namespace: 'pet' })), + ...PET_SPECIES_IDS.map(species => ({ + id: `adopt-${species}`, + title: `再次领养 ${species}`, + price: petAdoptionPrice(species), + namespace: 'pet', + })), + ...(['water', 'feeder'] as const).flatMap(device => + [1, 2].map(level => ({ + id: `upgrade-${device}-${level + 1}`, + title: `${device} Lv.${level + 1}`, + price: PET_DEVICE_UPGRADE_COST[device][level]!, + namespace: 'pet', + })) + ), + ] +} +export function preparePetPurchase( + state: PetState, + command: PetPurchaseCommand, + key: string, + at: number, +): PetPurchaseIntent { + let itemId: string, total: number, quantity = 1 + if (command.type === 'buy') { + itemId = command.productId + quantity = command.quantity ?? 1 + total = petProduct(itemId).price * quantity + } else if (command.type === 'adopt') { + itemId = `adopt-${command.species}` + total = petAdoptionPrice(command.species) + } else { + const level = deviceCapacity(state, command.device).tier + itemId = `upgrade-${command.device}-${level + 1}` + total = PET_DEVICE_UPGRADE_COST[command.device][level]! + } + if (!Number.isSafeInteger(total) || total < 0) throw new Error('商品价格无效') + const intent = { key, command: structuredClone(command), itemId, quantity, total, at } + // This proves complete local fulfilment is possible BEFORE creating a remote order. + grantPetPurchase(state, intent) + return intent +} +export function grantPetPurchase(state: PetState, intent: PetPurchaseIntent): PetTransition { + const funded = structuredClone(state) + // A temporary domain input is not a wallet credit: it is never persisted or sent. + funded.wallet.balance = intent.total + const result = applyPetCommand(funded, intent.command, { key: intent.key, now: intent.at }) + if (result.duplicate || result.receipt.coins !== -intent.total) throw new Error('购买意图与本地发货不一致') + result.state.wallet = structuredClone(state.wallet) + result.receipt.coins = 0 + migratePetState(result.state) + return result +} diff --git a/src/pet-store.ts b/src/pet-store.ts index 8c92a5a..5ebb1b9 100644 --- a/src/pet-store.ts +++ b/src/pet-store.ts @@ -1,5 +1,6 @@ import { applyPetCommand, migratePetState, settlePetUsage } from './pet-domain.js' import type { PetCommand, PetState, PetTransition, TrustedPetUsage } from './pet-domain.js' +import { isPetPurchase, petEconomyLocked } from './pet-economy-state.js' export type PetSnapshot = { revision: string | null; value: unknown } /** Bind to the public owner document API; CAS must atomically replace the whole document. */ export type PetStorageAdapter = { @@ -15,17 +16,33 @@ export class PetStore { this.#trustedUsageEnabled = options.trustedUsageEnabled ?? false this.#now = options.now ?? Date.now } - async read(): Promise { return migratePetState((await this.#adapter.load()).value) } + async read(): Promise { + return migratePetState((await this.#adapter.load()).value) + } async execute(command: PetCommand, idempotencyKey: string): Promise { const input = structuredClone(command) - return this.#commit((state, now) => applyPetCommand(state, input, { key: idempotencyKey, now })) + return this.#commit((state, now) => { + if (petEconomyLocked(state)) throw new Error('经济事务待对账,宠物存档已保护;恢复连接后继续') + if (state.economy && isPetPurchase(input)) throw new Error('共享购买必须通过经济服务完成') + return applyPetCommand(state, input, { key: idempotencyKey, now }) + }) } - async settleUsage(usage: TrustedPetUsage, idempotencyKey: string, isCurrent: () => boolean = () => true): Promise { + async settleUsage( + usage: TrustedPetUsage, + idempotencyKey: string, + isCurrent: () => boolean = () => true, + ): Promise { if (!this.#trustedUsageEnabled) throw new Error('可信 Token 用量接口尚未接入,奖励结算暂不可用') const input = structuredClone(usage) - return this.#commit((state, now) => settlePetUsage(state, input, { key: idempotencyKey, now }), isCurrent) + return this.#commit((state, now) => { + if (state.economy) throw new Error('共享经济不能从本地用量申报铸币') + return settlePetUsage(state, input, { key: idempotencyKey, now }) + }, isCurrent) } - async #commit(transition: (state: PetState, now: number) => PetTransition, isCurrent: () => boolean = () => true): Promise { + async #commit( + transition: (state: PetState, now: number) => PetTransition, + isCurrent: () => boolean = () => true, + ): Promise { const now = this.#now() for (let attempt = 0; attempt < 12; attempt++) { if (!isCurrent()) throw new Error('本次用量读取已失效') @@ -34,7 +51,9 @@ export class PetStore { const result = transition(migratePetState(snapshot.value), now) if (result.duplicate) return result migratePetState(result.state) - if (new TextEncoder().encode(JSON.stringify(result.state)).byteLength > 512 * 1024) throw new Error('宠物存档空间不足,本次操作未保存') + if (new TextEncoder().encode(JSON.stringify(result.state)).byteLength > 512 * 1024) { + throw new Error('宠物存档空间不足,本次操作未保存') + } if (!isCurrent()) throw new Error('本次用量读取已失效') if (await this.#adapter.compareAndSwap(snapshot.revision, result.state)) return result } diff --git a/src/pet-usage.ts b/src/pet-usage.ts index 86fbfb9..9afeac4 100644 --- a/src/pet-usage.ts +++ b/src/pet-usage.ts @@ -1,27 +1,34 @@ -import type { UsageV1, UsageSnapshotV1, UsageReadySnapshotV1 } from 'cordisx/contracts' -import type { TrustedPetUsage } from './pet-domain.js' +import type { UsageV2, WorkUsageSnapshotV2 } from '@cordisx/protocol/usage/v2' +import type { UsageReadySnapshotV1, UsageSnapshotV1, UsageV1 } from 'cordisx/contracts' +import type { WorkSnapshot } from './pet-work-rewards.js' export type PetUsageStatus = | { status: 'initializing' } | { status: 'unavailable'; reason: string } | { status: 'ready'; coverage: 'partial'; observedThrough: number; eligibleTokens: number } -export type PetUsageSettlement = (usage: TrustedPetUsage, key: string, isCurrent: () => boolean) => Promise -function counter(value: unknown): value is number { return Number.isSafeInteger(value) && Number(value) >= 0 } +function counter(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} function ready(value: UsageSnapshotV1): value is UsageReadySnapshotV1 { return value.schemaVersion === 1 && value.status === 'ready' && value.policyId === 'codex-local-input-output-v1' && typeof value.scopeId === 'string' && value.scopeId.length > 0 && value.scopeId.length <= 200 && typeof value.sourceId === 'string' && value.sourceId.length > 0 && value.sourceId.length <= 200 && typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 200 - && counter(value.revision) && counter(value.eligibleTokens) && counter(value.inputTokens) && counter(value.outputTokens) + && counter(value.revision) && counter(value.eligibleTokens) && counter(value.inputTokens) + && counter(value.outputTokens) && value.inputTokens + value.outputTokens === value.eligibleTokens && counter(value.observedThrough) && counter(value.enabledAt) && value.coverage === 'partial' } -export async function petUsageSourceKey(snapshot: Pick): Promise { - const bytes = new TextEncoder().encode(JSON.stringify([snapshot.scopeId, snapshot.sourceId, snapshot.epoch])) - const digest = await crypto.subtle.digest('SHA-256', bytes) - return `usage:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}` +export function readyWork(value: WorkUsageSnapshotV2): value is WorkSnapshot { + if (value.schemaVersion !== 2 || value.status !== 'ready' || value.policyId !== 'codex-local-work-input-output-v2') { + return false + } + const c = value.classification + return !!c && c.version === 'host-game-cwd-v1' && c.hostGameTasks === 'excluded' + && c.forksAndSubagents === 'excluded' && c.unknownSources === 'excluded' + && ready({ ...value, schemaVersion: 1, policyId: 'codex-local-input-output-v1' }) } /** Public invalidations are hints. Serialize reads and fence every async stage so a - * superseding permission change cannot commit a stale ready result. */ + * superseding permission change cannot publish stale attribution status. */ export class PetUsageController { #closed = false #started = false @@ -30,27 +37,34 @@ export class PetUsageController { #running: Promise | undefined #unsubscribe: (() => void) | undefined constructor( - private readonly usage: UsageV1, - private readonly settle: PetUsageSettlement, + private readonly usage: UsageV1 | UsageV2, private readonly publish: (status: PetUsageStatus) => void, + private readonly reward?: (snapshot: WorkSnapshot, current: () => boolean) => Promise, + private readonly pause?: () => void, ) {} async start(): Promise { if (this.#closed || this.#started) return this.#started = true this.publish({ status: 'initializing' }) try { - this.#unsubscribe = this.usage.subscribe(() => { void this.refresh() }) + this.#unsubscribe = this.usage.subscribe(() => { + void this.refresh() + }) await this.refresh() - } catch { this.publish({ status: 'unavailable', reason: 'host-unavailable' }) } + } catch { + this.publish({ status: 'unavailable', reason: 'host-unavailable' }) + } } refresh(): Promise { if (this.#closed) return Promise.resolve() this.#generation++ this.#dirty = true - if (!this.#running) this.#running = this.drain().finally(() => { - this.#running = undefined - if (this.#dirty && !this.#closed) void this.refresh() - }) + if (!this.#running) { + this.#running = this.drain().finally(() => { + this.#running = undefined + if (this.#dirty && !this.#closed) void this.refresh() + }) + } return this.#running } private async drain(): Promise { @@ -59,22 +73,39 @@ export class PetUsageController { const generation = this.#generation const current = () => !this.#closed && generation === this.#generation try { - const snapshot = await this.usage.read() + const v2 = 'readWork' in this.usage && typeof this.usage.readWork === 'function' + const snapshot = v2 ? await (this.usage as UsageV2).readWork() : await this.usage.read() if (!current()) continue if (snapshot.status === 'unavailable') { + this.pause?.() this.publish({ status: 'unavailable', reason: snapshot.reason }) continue } - if (!ready(snapshot)) { + if (v2 && readyWork(snapshot as WorkUsageSnapshotV2)) { + await this.reward?.(snapshot as WorkSnapshot, current) + if (current()) { + this.publish({ + status: 'ready', + coverage: 'partial', + observedThrough: snapshot.observedThrough, + eligibleTokens: snapshot.eligibleTokens, + }) + } + continue + } + this.pause?.() + if (v2 || !ready(snapshot as UsageSnapshotV1)) { this.publish({ status: 'unavailable', reason: 'invalid-snapshot' }) continue } - const sourceId = await petUsageSourceKey(snapshot) - if (!current()) continue - await this.settle({ sourceId, totalTokens: snapshot.eligibleTokens, revision: snapshot.revision }, `${sourceId}:${snapshot.revision}`, current) - if (current()) this.publish({ status: 'ready', coverage: 'partial', observedThrough: snapshot.observedThrough, eligibleTokens: snapshot.eligibleTokens }) + // This profile aggregate cannot distinguish game inference from eligible work. + // Do not persist a frontier or a deferred reward: neither is trusted evidence. + this.publish({ status: 'unavailable', reason: 'usage-attribution-unavailable' }) } catch { - if (current()) this.publish({ status: 'unavailable', reason: 'settlement-unavailable' }) + if (current()) { + this.pause?.() + this.publish({ status: 'unavailable', reason: 'host-unavailable' }) + } } } } diff --git a/src/pet-work-reward-state.ts b/src/pet-work-reward-state.ts new file mode 100644 index 0000000..82923f4 --- /dev/null +++ b/src/pet-work-reward-state.ts @@ -0,0 +1,68 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { CordisXJsonValue } from 'cordisx/contracts' +export type WorkCursor = { + scopeId: string + sourceId: string + epoch: string + revision: number + tokens: number + observedThrough: number +} +export type RewardIdentity = { baseUrl: string; instanceId: string; accountId: string; sourceId: string } +export type WorkRewardState = { + version: 2 + identity: RewardIdentity + cursor: WorkCursor + remainder: number + earned: number + pending?: { eventId: string; amount: number; gap: boolean } + lastEventId?: string +} +export interface WorkRewardStorage { + load(): Promise<{ revision: number; value: unknown }> + save(revision: number, value: WorkRewardState): Promise +} +export const sameRewardIdentity = (a: RewardIdentity, b: RewardIdentity): boolean => + a.baseUrl === b.baseUrl && a.instanceId === b.instanceId && a.accountId === b.accountId && a.sourceId === b.sourceId +export function validateWorkRewards(value: unknown): WorkRewardState { + const v = value as WorkRewardState + const id = (x: unknown) => typeof x === 'string' && x.length > 0 && x.length <= 2048 + const count = (x: unknown) => Number.isSafeInteger(x) && Number(x) >= 0 + if ( + !v || v.version !== 2 || !v.identity || !Object.values(v.identity).every(id) + || !['baseUrl', 'instanceId', 'accountId', 'sourceId'].every(k => id(v.identity[k as keyof RewardIdentity])) + || !v.cursor || !id(v.cursor.scopeId) || !id(v.cursor.sourceId) || !id(v.cursor.epoch) + || !count(v.cursor.revision) || !count(v.cursor.tokens) || !count(v.cursor.observedThrough) + || !count(v.remainder) || v.remainder >= 10000 || !count(v.earned) + || (v.pending && (!/^pet-work:[a-f0-9]{64}$/.test(v.pending.eventId) || !count(v.pending.amount) + || v.pending.amount < 1 || typeof v.pending.gap !== 'boolean')) + || (v.lastEventId !== undefined && !/^pet-work:[a-f0-9]{64}$/.test(v.lastEventId)) + ) { + throw new Error('工作奖励记录无效;原记录保留等待恢复') + } + return structuredClone(v) +} +export function workRewardStorage(documents: Context['documents']): WorkRewardStorage { + const documentId = 'pet-work-rewards-v2' + return { + async load() { + const result = await documents.load(documentId) + if (result.status === 'unavailable') throw new Error(result.diagnostic) + return result.status === 'missing' + ? { revision: 0, value: null } + : { revision: result.snapshot.revision, value: result.snapshot.value } + }, + async save(revision, value) { + validateWorkRewards(value) + const result = await documents.transaction({ + contract: 'cordisx.owner-documents/v1', + documentId, + expectedRevision: revision, + schemaVersion: 2, + value: value as unknown as CordisXJsonValue, + }) + if (result.status === 'unavailable') throw new Error(result.diagnostic) + return result.status === 'accepted' + }, + } +} diff --git a/src/pet-work-rewards.ts b/src/pet-work-rewards.ts new file mode 100644 index 0000000..0797234 --- /dev/null +++ b/src/pet-work-rewards.ts @@ -0,0 +1,214 @@ +import type { EconomyClient } from '@cordisx/economy/client' +import type { WorkUsageSnapshotV2 } from '@cordisx/protocol/usage/v2' +import { PET_ECONOMY } from './pet-catalog.js' +import { + type RewardIdentity, + sameRewardIdentity, + validateWorkRewards, + type WorkCursor, + type WorkRewardState, + type WorkRewardStorage, +} from './pet-work-reward-state.js' +export type WorkSnapshot = Extract +export type WorkRewardStatus = { status: 'unavailable' | 'ready' | 'pending'; reason: string; earned?: number } +export type WorkSponsor = { sdk: EconomyClient; identity: RewardIdentity; dispose(): void } +export const workCursor = (s: WorkSnapshot): WorkCursor => ({ + scopeId: s.scopeId, + sourceId: s.sourceId, + epoch: s.epoch, + revision: s.revision, + tokens: s.eligibleTokens, + observedThrough: s.observedThrough, +}) +const sameEpoch = (a: WorkCursor, b: WorkCursor) => + a.scopeId === b.scopeId && a.sourceId === b.sourceId && a.epoch === b.epoch +async function eventId(identity: RewardIdentity, from: WorkCursor, to: WorkCursor): Promise { + const bytes = new TextEncoder().encode( + JSON.stringify([identity.baseUrl, identity.instanceId, identity.accountId, identity.sourceId, from, to]), + ) + return 'pet-work:' + + Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')) + .join('') +} +/** At most one persisted grant. No local wallet writes, speculative credit, or catch-up queue. */ +export class PetWorkRewards { + #sponsor: WorkSponsor | undefined + #baseline = true + #closed = false + #generation = 0 + #running: Promise = Promise.resolve() + constructor( + private readonly storage: WorkRewardStorage, + private readonly publish: (status: WorkRewardStatus) => void, + ) {} + connect(sponsor: WorkSponsor): void { + this.#sponsor?.dispose() + this.#sponsor = sponsor + this.#generation++ + this.#baseline = true + } + pause(reason = '工作奖励未配置赞助连接;期间用量不补发'): void { + this.#baseline = true + this.publish({ status: 'unavailable', reason }) + } + observe(snapshot: WorkSnapshot, wallet: RewardIdentity | undefined, valid: () => boolean): Promise { + const run = this.#running.then(() => this.process(snapshot, wallet, valid)) + this.#running = run.catch(() => {}) + return run + } + private async process( + snapshot: WorkSnapshot, + wallet: RewardIdentity | undefined, + valid: () => boolean, + ): Promise { + const sponsor = this.#sponsor, generation = this.#generation + const current = () => !this.#closed && generation === this.#generation && valid() + if (!current()) return + if (!sponsor || !wallet) { + this.pause() + return + } + if (!sameRewardIdentity(wallet, sponsor.identity)) { + this.pause('工作赞助与共享钱包身份不匹配') + return + } + const cursor = workCursor(snapshot) + try { + for (let retry = 0; retry < 12 && current(); retry++) { + const record = await this.storage.load() + if (!current()) return + const prior = record.value === null ? undefined : validateWorkRewards(record.value) + if (prior && !sameRewardIdentity(prior.identity, wallet)) { + throw new Error('请恢复原工作赞助来源与账户;不覆盖历史奖励记录') + } + if (prior?.pending) { + // A newer observation during an unresolved intent is a gap, never queued work. + if ( + (!sameEpoch(prior.cursor, cursor) || cursor.tokens > prior.cursor.tokens || this.#baseline) + && !prior.pending.gap + ) { + prior.pending.gap = true + if (!await this.storage.save(record.revision, prior)) continue + continue + } + await this.deliver(prior, current) + return + } + const source = await sponsor.sdk.rewardSource(wallet.sourceId, wallet.accountId) + if (!current()) return + if ( + source.instanceId !== wallet.instanceId || source.accountId !== wallet.accountId + || source.sourceId !== wallet.sourceId + ) { + throw new Error('工作赞助服务身份不匹配') + } + if ( + prior && sameEpoch(prior.cursor, cursor) + && (cursor.tokens < prior.cursor.tokens || cursor.revision < prior.cursor.revision + || cursor.observedThrough < prior.cursor.observedThrough) + ) throw new Error('工作用量快照回退;保留原水位') + const reset = this.#baseline || !prior || !sameEpoch(prior.cursor, cursor) + || cursor.tokens < prior.cursor.tokens || cursor.revision < prior.cursor.revision + || cursor.observedThrough < prior.cursor.observedThrough + if (!reset && cursor.revision === prior!.cursor.revision) { + if (cursor.tokens !== prior!.cursor.tokens) throw new Error('同一工作修订的用量不一致') + this.publish({ + status: 'ready', + reason: '正常工作奖励已启用 · 每 10,000 Token 获得 1 币', + earned: prior!.earned, + }) + return + } + const eligible = reset ? 0 : cursor.tokens - prior!.cursor.tokens + prior!.remainder + if (!Number.isSafeInteger(eligible)) throw new Error('工作用量超出安全范围') + const amount = Math.floor(eligible / PET_ECONOMY.tokensPerCoin) + const limited = amount > source.available || amount > source.dailyLimit - source.dailyGranted + || amount > source.accountDailyLimit - source.accountDailyGranted + const next: WorkRewardState = { + version: 2, + identity: wallet, + cursor, + earned: prior?.earned ?? 0, + remainder: limited ? 0 : eligible % PET_ECONOMY.tokensPerCoin, + ...(prior?.lastEventId ? { lastEventId: prior.lastEventId } : {}), + } + if (amount && !limited) { + next.pending = { eventId: await eventId(wallet, prior!.cursor, cursor), amount, gap: false } + } + if (!current()) return + if (!await this.storage.save(record.revision, next)) continue + this.#baseline = false + if (next.pending) await this.deliver(next, current) + else if (current()) { + this.publish({ + status: limited ? 'unavailable' : 'ready', + earned: next.earned, + reason: limited + ? '赞助余额或当日额度不足;这段用量不补发' + : reset + ? '正常工作奖励已启用,已建立新基线' + : '正常工作奖励已启用 · 每 10,000 Token 获得 1 币', + }) + } + return + } + if (current()) throw new Error('工作奖励记录繁忙,请稍后同步') + } catch (error) { + this.#baseline = true + if (current()) { + this.publish({ + status: 'pending', + reason: `${ + error instanceof Error ? error.message : '工作奖励暂不可用' + };已记录的事务保留原标识恢复,期间不累计新欠账`, + }) + } + } + } + private async deliver(state: WorkRewardState, current: () => boolean): Promise { + const pending = state.pending!, sponsor = this.#sponsor! + if (!current()) return + const receipt = await sponsor.sdk.grant({ + sourceId: state.identity.sourceId, + accountId: state.identity.accountId, + expectedInstanceId: state.identity.instanceId, + eventId: pending.eventId, + amount: pending.amount, + }, pending.eventId) + if (!current()) return + if ( + receipt.instanceId !== state.identity.instanceId || receipt.accountId !== state.identity.accountId + || receipt.sourceId !== state.identity.sourceId || receipt.eventId !== pending.eventId + || receipt.amount !== pending.amount + ) { + throw new Error('工作奖励收据身份不匹配') + } + for (let retry = 0; retry < 12 && current(); retry++) { + const record = await this.storage.load() + if (!current()) return + const next = validateWorkRewards(record.value) + if (!sameRewardIdentity(next.identity, state.identity)) throw new Error('工作奖励账户变化') + if (next.lastEventId === pending.eventId && !next.pending) { + this.#baseline ||= pending.gap + this.publish({ status: 'ready', reason: '工作奖励已同步', earned: next.earned }) + return + } + if (next.pending?.eventId !== pending.eventId) throw new Error('工作奖励事务已改变,请重新同步') + this.#baseline ||= next.pending.gap + next.earned += pending.amount + if (!Number.isSafeInteger(next.earned)) throw new Error('累计工作奖励超出安全范围') + next.lastEventId = pending.eventId + delete next.pending + if (!await this.storage.save(record.revision, next)) continue + if (current()) this.publish({ status: 'ready', reason: '工作奖励已存入共享钱包', earned: next.earned }) + return + } + if (current()) throw new Error('工作奖励收据保存繁忙') + } + dispose(): void { + this.#closed = true + this.#generation++ + this.#sponsor?.dispose() + this.#sponsor = undefined + } +} diff --git a/src/pet-work-sponsor.ts b/src/pet-work-sponsor.ts new file mode 100644 index 0000000..e9c6522 --- /dev/null +++ b/src/pet-work-sponsor.ts @@ -0,0 +1,42 @@ +import { EconomyClient } from '@cordisx/economy/client' +import type { HttpClientV1 } from '@cordisx/protocol/plugin-http/v1' +import { petHostTransport } from './pet-economy-host.js' +import type { RewardIdentity } from './pet-work-reward-state.js' +import type { WorkSponsor } from './pet-work-rewards.js' +/** Separate masked service consent. Neither service credentials nor handles enter Pet documents. */ +export function petWorkSponsorConnector( + http: HttpClientV1 | undefined, + sourceId: string | undefined, +): ((identity: Omit) => Promise) | undefined { + if (!http || !sourceId?.trim()) return undefined + const source = sourceId.trim() + return async wallet => { + if (!/^[A-Za-z0-9._:-]{1,120}$/.test(source)) throw new Error('工作赞助来源标识无效') + const origin = new URL(wallet.baseUrl).origin + const result = await http.authorize({ origin, credential: 'bearer' }) + if (result.status !== 'accepted') throw new Error(`工作赞助授权未完成:${result.code}`) + const connection = result.value + const lifetime = new AbortController() + const dispose = () => { + lifetime.abort() + void http.revoke(connection) + } + try { + if ( + connection.contract !== 'cordisx.http-connection/v1' || connection.origin !== origin + || connection.credential !== 'bearer' + ) { + throw new Error('工作赞助授权范围不匹配') + } + const sdk = new EconomyClient(wallet.baseUrl, petHostTransport(http, connection, lifetime.signal)) + const state = await sdk.rewardSource(source, wallet.accountId) + if (state.instanceId !== wallet.instanceId || state.accountId !== wallet.accountId || state.sourceId !== source) { + throw new Error('工作赞助来源与钱包不属于同一实例或账户') + } + return { sdk, identity: { ...wallet, sourceId: source }, dispose } + } catch (error) { + dispose() + throw error + } + } +} diff --git a/src/plugin-composer-animal.tsx b/src/plugin-composer-animal.tsx index d617e81..5e81d56 100644 --- a/src/plugin-composer-animal.tsx +++ b/src/plugin-composer-animal.tsx @@ -1,7 +1,10 @@ +import Schema from '@deepseek-ai/schemastery' +import { type PetEconomyConfig, petEconomyConnector } from './pet-economy-host.js' +import { petWorkSponsorConnector } from './pet-work-sponsor.js' import '@oneworks/avatar-react/renderer.css' import type { Context } from '@deepseek-ai/cordis' -import { defineReactVisual } from 'cordisx/react' import { CORDISX_PLUGIN_MANIFEST_SCHEMA_V11, type CordisXPluginManifestV11 } from 'cordisx/contracts' +import { defineReactVisual } from 'cordisx/react' import { PetClient } from './pet-client.js' import { installPetPages } from './pet-navigation.js' @@ -14,31 +17,92 @@ export const manifest = { services: [], capabilities: [ { name: 'usage.read', required: false, scope: { profile: 'current' } }, - { name: 'ui.extension-points.render', required: true, scope: { extensionPoints: ['composer.primary-action.visual', 'composer.frame.overlay'] } }, - { name: 'ui.extension-points.interact', required: false, scope: { extensionPoints: ['composer.primary-action.visual', 'composer.frame.overlay'], events: ['pointer.observe', 'drag', 'activate'] } }, + { + name: 'ui.extension-points.render', + required: true, + scope: { extensionPoints: ['composer.primary-action.visual', 'composer.frame.overlay'] }, + }, + { + name: 'ui.extension-points.interact', + required: false, + scope: { + extensionPoints: ['composer.primary-action.visual', 'composer.frame.overlay'], + events: ['pointer.observe', 'drag', 'activate'], + }, + }, ], } as const satisfies CordisXPluginManifestV11 -export const inject = ['extensionPointVisuals', 'documents', 'pages', 'routes', 'slots', 'managerContent', 'usage'] -export function apply(ctx: Context): void { - const client = new PetClient(ctx.documents, undefined, ctx.usage) +export const Config = Schema.object({ + economyBaseUrl: Schema.string().default('').description( + '共享经济服务地址;HTTPS 或本机回环地址。凭证由 Host 授权窗口管理。', + ), + workRewardSourceId: Schema.string().default('').description( + '管理员预注资的工作奖励来源;使用单独的服务授权,未配置期间不补发。', + ), + migrationSourceId: Schema.string().default('pet-migration-v1').description( + '管理员提供的永久迁移来源;迁移后不能更换。', + ), +}) +export const inject = [ + 'extensionPointVisuals', + 'documents', + 'pages', + 'routes', + 'slots', + 'managerContent', + 'usage', + 'http', +] +export function apply(ctx: Context, config: PetEconomyConfig = {}): void { + const client = new PetClient( + ctx.documents, + undefined, + ctx.usage, + undefined, + petEconomyConnector(ctx.http, config), + petWorkSponsorConnector(ctx.http, config.workRewardSourceId), + ) const navigate = installPetPages(ctx, client) ctx.effect(() => { let visuals: (() => void)[] = [] const update = () => { const state = client.getSnapshot().state const visible = state?.settings.visible === true && state.pets.some(pet => pet.status === 'alive') - if (visible && visuals.length === 0) visuals = [ - ctx.extensionPointVisuals.register({ id: 'animal', pointId: 'composer.primary-action.visual', snapshotVersion: 2, events: ['pointer.observe'] }, - async () => defineReactVisual((await import('./pet-visuals.js')).createPetPrimary(client), { kind: 'react-dom-v1' })), - ctx.extensionPointVisuals.register({ id: 'gaze', pointId: 'composer.frame.overlay', snapshotVersion: 2, events: ['pointer.observe', 'drag', 'activate'] }, - async () => defineReactVisual((await import('./pet-visuals.js')).createPetOverlay(client, navigate), { kind: 'react-dom-v1' })), - ] - else if (!visible && visuals.length) { for (const dispose of visuals) dispose(); visuals = [] } + if (visible && visuals.length === 0) { + visuals = [ + ctx.extensionPointVisuals.register( + { + id: 'animal', + pointId: 'composer.primary-action.visual', + snapshotVersion: 2, + events: ['pointer.observe'], + }, + async () => + defineReactVisual((await import('./pet-visuals.js')).createPetPrimary(client), { kind: 'react-dom-v1' }), + ), + ctx.extensionPointVisuals.register({ + id: 'gaze', + pointId: 'composer.frame.overlay', + snapshotVersion: 2, + events: ['pointer.observe', 'drag', 'activate'], + }, async () => + defineReactVisual((await import('./pet-visuals.js')).createPetOverlay(client, navigate), { + kind: 'react-dom-v1', + })), + ] + } else if (!visible && visuals.length) { + for (const dispose of visuals) dispose() + visuals = [] + } } const unsubscribe = client.subscribe(update) update() - return () => { unsubscribe(); for (const dispose of visuals) dispose(); client.dispose() } + return () => { + unsubscribe() + for (const dispose of visuals) dispose() + client.dispose() + } }) void client.start() } diff --git a/test/economy-dependencies.mjs b/test/economy-dependencies.mjs new file mode 100644 index 0000000..efc9773 --- /dev/null +++ b/test/economy-dependencies.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +test('economy consumer package inputs are portable, SHA-pinned and checksum verified', async () => { + const record = JSON.parse(await readFile('.development/economy-dependencies.json', 'utf8')) + const pkg = JSON.parse(await readFile('package.json', 'utf8')) + for (const artifact of record.artifacts) { + assert.match(artifact.sourceCommit, /^[a-f0-9]{40}$/) + assert.match(artifact.repository, /^https:\/\/github.com\/cordisx\//) + const path = `.development/${artifact.artifact}` + assert.equal({ ...pkg.dependencies, ...pkg.devDependencies }[artifact.package], `file:${path}`) + assert.equal(createHash('sha256').update(await readFile(path)).digest('hex'), artifact.sha256) + } +}) diff --git a/test/manifest.mjs b/test/manifest.mjs index 395e104..d09867b 100644 --- a/test/manifest.mjs +++ b/test/manifest.mjs @@ -8,7 +8,7 @@ test('declares exact controlled render and optional pointer capabilities', () => assert.equal(manifest.id, 'plugin-composer-animal') assert.deepEqual(manifest.capabilities.map(item => item.name), ['usage.read', 'ui.extension-points.render', 'ui.extension-points.interact']) assert.deepEqual(manifest.capabilities[0], { name: 'usage.read', required: false, scope: { profile: 'current' } }) - assert.deepEqual(inject, ['extensionPointVisuals', 'documents', 'pages', 'routes', 'slots', 'managerContent', 'usage']) + assert.deepEqual(inject, ['extensionPointVisuals', 'documents', 'pages', 'routes', 'slots', 'managerContent', 'usage', 'http']) }) test('activation registers lazy visuals after loading; hiding and disposal restore native seats', async () => { const registered = [] diff --git a/test/pet-client.mjs b/test/pet-client.mjs index ff31d9a..9e41127 100644 --- a/test/pet-client.mjs +++ b/test/pet-client.mjs @@ -175,24 +175,24 @@ test('HMR replacement keeps one timer and a disposed pending startup cannot writ assert.equal(clock.pending(), 1) }) -test('client binds only the public usage service and exposes ready/denied state with durable wallet updates', async t => { +test('client exposes attribution/denied state and never rewards partial profile usage', async t => { const clock = fakeClock(), bridge = documents() const listeners = new Set() let snapshot = { schemaVersion: 1, status: 'ready', scopeId: 'profile', sourceId: 'rollouts', epoch: 'one', revision: 0, eligibleTokens: 0, inputTokens: 0, outputTokens: 0, policyId: 'codex-local-input-output-v1', enabledAt: 0, observedThrough: 1000, coverage: 'partial', diagnostics: [] } const usage = { read: async () => snapshot, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) } } const client = new PetClient(bridge, clock.runtime, usage); t.after(() => client.dispose()) await client.start() - assert.equal(client.getSnapshot().usage.status, 'ready') + assert.equal(client.getSnapshot().usage.reason, 'usage-attribution-unavailable') snapshot = { ...snapshot, revision: 1, eligibleTokens: 10000, inputTokens: 10000 } await client.refreshUsage() - assert.equal(client.getSnapshot().state.wallet.balance, 1) + assert.equal(client.getSnapshot().state.wallet.balance, 0) const writes = bridge.commits() await client.refreshUsage() assert.equal(bridge.commits(), writes) snapshot = { schemaVersion: 1, status: 'unavailable', reason: 'permission-denied', diagnostics: [] } await client.refreshUsage() assert.equal(client.getSnapshot().usage.reason, 'permission-denied') - assert.equal(client.getSnapshot().state.wallet.balance, 1) + assert.equal(client.getSnapshot().state.wallet.balance, 0) client.dispose() assert.equal(listeners.size, 0) }) diff --git a/test/pet-domain.mjs b/test/pet-domain.mjs index bbe69e2..de32af9 100644 --- a/test/pet-domain.mjs +++ b/test/pet-domain.mjs @@ -107,7 +107,7 @@ test('position, visibility and active selection preserve stable identities', () }) test('corrupt economy and future versions fail closed rather than silently reset', () => { assert.equal(migratePetState(null).pets.length, 1) - assert.throws(() => migratePetState({ ...createPetState(), version: 2 }), /版本/) + assert.throws(() => migratePetState({ ...createPetState(), version: 3 }), /版本/) const bad = createPetState(); bad.wallet.balance = 100 assert.throws(() => migratePetState(bad), /账目/) }) diff --git a/test/pet-economy-host.mjs b/test/pet-economy-host.mjs new file mode 100644 index 0000000..cd2c9a2 --- /dev/null +++ b/test/pet-economy-host.mjs @@ -0,0 +1,107 @@ +import { build } from 'esbuild' +import assert from 'node:assert/strict' +import test from 'node:test' +const bundle = await build({ + entryPoints: ['src/pet-economy-host.ts'], + bundle: true, + format: 'esm', + platform: 'node', + write: false, +}) +const { petHostTransport, petEconomyConnector } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}` +) +const connection = { + contract: 'cordisx.http-connection/v1', + id: 'opaque-handle', + origin: 'https://economy.test', + credential: 'bearer', +} +test('SDK transport uses only the public authorized handle, allowed headers, deadline and cancellation', async () => { + const requests = [], lifetime = new AbortController() + const http = { + request: async request => { + requests.push(request) + return { status: 'accepted', value: { statusCode: 200, body: '{}', contentType: 'application/json' } } + }, + } + const transport = petHostTransport(http, connection, lifetime.signal) + const before = Date.now() + assert.deepEqual( + await transport({ + method: 'POST', + url: 'https://economy.test/v1/orders', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'stable-request' }, + body: '{}', + }), + { status: 200, body: '{}' }, + ) + assert.equal(requests.length, 1) + assert.deepEqual(requests[0].headers, { 'content-type': 'application/json', 'idempotency-key': 'stable-request' }) + assert.equal(requests[0].connection, connection) + assert.equal(requests[0].path, '/v1/orders') + assert.ok(requests[0].deadline >= before && requests[0].deadline <= Date.now() + 30000) + lifetime.abort() + assert.equal(requests[0].signal.aborted, true) + await assert.rejects(transport({ method: 'GET', url: 'https://attacker.test/v1/me', headers: {} }), /超出/) + await assert.rejects( + transport({ method: 'GET', url: 'https://economy.test/v1/me', headers: { Authorization: 'never-allowed' } }), + /头部/, + ) + assert.equal(requests.length, 1) +}) +test('connection is an explicit Host consent action and a generation disposal aborts every SDK request', async () => { + let authorizations = 0, last + const http = { + authorize: async input => { + authorizations++ + assert.deepEqual(input, { origin: connection.origin, credential: 'bearer' }) + return { status: 'accepted', value: connection } + }, + request: async input => { + last = input + return { + status: 'accepted', + value: { + statusCode: 200, + body: JSON.stringify({ instanceId: 'one', accountId: 'alice', available: 9, reserved: 0 }), + }, + } + }, + revoke: async () => ({ status: 'accepted', value: null }), + } + const connect = petEconomyConnector(http, { + economyBaseUrl: 'https://economy.test', + migrationSourceId: 'permanent-source', + }) + assert.equal(authorizations, 0) + const result = await connect() + assert.deepEqual(result.binding, { instanceId: 'one', accountId: 'alice' }) + assert.equal(result.sourceId, 'permanent-source') + assert.equal(authorizations, 1) + assert.doesNotMatch(JSON.stringify(result), /opaque-handle|secret|token/) + result.dispose() + assert.equal(last.signal.aborted, true) + assert.equal(petEconomyConnector(undefined, {}), undefined) +}) +test('denied, stale and invalid endpoint errors remain explicit without a fallback fetch or token field', async () => { + let calls = 0 + const http = { + authorize: async () => { + calls++ + return { status: 'unavailable', code: 'denied' } + }, + } + await assert.rejects(petEconomyConnector(http, { economyBaseUrl: 'https://economy.test' })(), /denied/) + await assert.rejects(petEconomyConnector(http, { economyBaseUrl: 'http://unsafe.test' })(), /HTTPS/) + assert.equal(calls, 1) + const transport = petHostTransport( + { request: async () => ({ status: 'unavailable', code: 'stale-generation' }) }, + connection, + new AbortController().signal, + ) + await assert.rejects( + transport({ method: 'GET', url: connection.origin + '/v1/me', headers: {} }), + /stale-generation.*原事务保留/, + ) +}) diff --git a/test/pet-economy-integration.mjs b/test/pet-economy-integration.mjs new file mode 100644 index 0000000..b633829 --- /dev/null +++ b/test/pet-economy-integration.mjs @@ -0,0 +1,382 @@ +import { createEconomyServer, Economy } from '@cordisx/economy/server' +import { build } from 'esbuild' +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +const bundle = await build({ + stdin: { + contents: + `export * from '@cordisx/economy/client'; export * from './src/pet-economy.ts'; export * from './src/pet-economy-journal.ts'; export * from './src/pet-domain.ts'; export * from './src/pet-purchase.ts';`, + resolveDir: process.cwd(), + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, +}) +const { + EconomyClient, + bearerTransport, + PetEconomy, + PetEconomyJournal, + createPetState, + settlePetUsage, + petMerchantCatalog, + petSnapshotHash, +} = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`) +const binding = { instanceId: 'test-instance', accountId: 'alice' } +const sourceId = 'pet-migration-v1' +function documentStore(initial) { + let value = structuredClone(initial), revision = 1, fail = false + const backups = new Map() + const storage = { + load: async () => ({ value: structuredClone(value), revision: String(revision) }), + compareAndSwap: async (expected, next) => { + if (fail) throw new Error('isolated local write unavailable') + if (expected !== String(revision)) return false + value = structuredClone(next) + revision++ + return true + }, + } + const backup = { + create: async (key, value) => { + if (!backups.has(key)) backups.set(key, structuredClone(value)) + }, + load: async key => structuredClone(backups.get(key)), + } + return { + storage, + backup, + read: () => structuredClone(value), + fail: value => { + fail = value + }, + backups, + } +} +function oldWallet(coins) { + const baseline = + settlePetUsage(createPetState(), { sourceId: 'isolated-historical', totalTokens: 0 }, { key: 'baseline', now: 0 }) + .state + return settlePetUsage(baseline, { sourceId: 'isolated-historical', totalTokens: coins * 10000 }, { + key: 'income', + now: 1, + }).state +} +async function fixture(t, { initial = oldWallet(100), approve = true } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'pet-sdk-integration-')) + const serverEconomy = new Economy(join(dir, 'ledger.sqlite')) + serverEconomy.auth.createInstance(binding.instanceId, 10000) + serverEconomy.auth.createAccount(binding.instanceId, 'alice') + serverEconomy.auth.createAccount(binding.instanceId, 'bob') + const tokens = Object.fromEntries( + ['alice', 'bob'].map( + account => [account, serverEconomy.auth.login(serverEconomy.auth.enrollment(binding.instanceId, account)).token], + ), + ) + const sponsor = serverEconomy.auth.createService(binding.instanceId, 'sponsor', '*', 1000).token + const gameToken = serverEconomy.auth.createService(binding.instanceId, 'game', '*', 1000).token + serverEconomy.commerce.createSource(binding.instanceId, sourceId, 'sponsor', 'migration', 5000, 1000, 1000) + serverEconomy.commerce.createSource(binding.instanceId, 'test-initial', 'sponsor', 'reward', 1000, 1000, 1000) + for (const item of petMerchantCatalog()) { + serverEconomy.commerce.createItem(binding.instanceId, item.id, item.title, item.price, item.namespace) + } + const server = createEconomyServer(serverEconomy) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + t.after(async () => { + await new Promise(resolve => server.close(resolve)) + serverEconomy.close() + await rm(dir, { recursive: true, force: true }) + }) + const baseUrl = `http://127.0.0.1:${server.address().port}` + const sdkFor = token => new EconomyClient(baseUrl, bearerTransport(fetch, () => token)) + const sdk = sdkFor(tokens.alice), bob = sdkFor(tokens.bob), game = sdkFor(gameToken) + await sdkFor(sponsor).grant( + { sourceId: 'test-initial', accountId: 'bob', eventId: 'bob-test-grant', amount: 100 }, + 'test-grant-bob', + ) + if (approve && initial.wallet.balance > 0) { + serverEconomy.commerce.entitlement( + binding.instanceId, + sourceId, + await petSnapshotHash(initial), + 'alice', + initial.wallet.balance, + ) + } + const doc = documentStore(initial), + journal = new PetEconomyJournal(doc.storage, doc.backup, () => 1000), + statuses = [] + const pet = new PetEconomy(sdk, journal, binding, sourceId, status => statuses.push(status)) + t.after(() => pet.dispose()) + return { + serverEconomy, + sdk, + sdkFor, + tokens, + game, + bob, + doc, + journal, + statuses, + pet, + baseUrl, + sponsor: sdkFor(sponsor), + } +} +test('real SDK + HTTP + SQLite game settlement is spendable in Pet, replay never debits or delivers twice', async t => { + const f = await fixture(t) + await f.pet.migrate() + const agreement = await f.game.createAgreement({ + matchId: 'pet-cross-plugin-match', + game: { id: 'gomoku', version: '1.0.0', digest: 'a'.repeat(64), reviewStatus: 'unreviewed' }, + participants: [{ accountId: 'alice', amount: 100 }, { accountId: 'bob', amount: 100 }], + settlementPolicy: { + kind: 'enumerated', + outcomes: [{ id: 'alice-wins', payouts: [{ accountId: 'alice', amount: 200 }] }], + }, + expiresAt: Date.now() + 60000, + }, 'test-create-match') + await f.sdk.reserve({ agreementId: agreement.id, termsHash: agreement.termsHash }, 'test-reserve-alice') + await f.bob.reserve({ agreementId: agreement.id, termsHash: agreement.termsHash }, 'test-reserve-bob') + await f.game.settle( + { agreementId: agreement.id, termsHash: agreement.termsHash, outcomeId: 'alice-wins' }, + 'test-game-settlement', + ) + assert.equal((await f.sdk.me()).available, 200) + const command = { type: 'buy', productId: 'item-auto-feeder' } + await f.pet.purchase(command, 'pet-buy-device-1') + assert.equal(f.doc.read().itemInventory['item-auto-feeder'], 1) + assert.equal((await f.sdk.me()).available, 20) + assert.equal(f.statuses.at(-1).wallet.available, 20) + await f.pet.purchase(command, 'pet-buy-device-1') + await f.pet.refresh() + assert.equal((await f.sdk.orders()).length, 1) + assert.equal((await f.sdk.me()).available, 20) + assert.equal(f.doc.read().economy.receipts.length, 1) + assert.equal(f.doc.read().wallet.balance, 100) // untouched legacy audit, not spendable + await assert.rejects(f.pet.purchase(command, 'pet-buy-device-2'), /已拥有/) + await assert.rejects( + f.sdk.claim({ sourceId, entitlementId: f.doc.read().economy.migration.snapshotHash }, 'new-migration-key'), + error => error.code === 'MIGRATION_CONSUMED', + ) + f.serverEconomy.store.assertConservation(binding.instanceId) +}) +test('lost purchase response and offline local delivery reconcile the same receipt after reload', async t => { + const f = await fixture(t) + await f.pet.migrate() + let drop = true + const transport = bearerTransport(fetch, () => f.tokens.alice) + const interrupted = new EconomyClient(f.baseUrl, async request => { + const result = await transport(request) + if (drop && request.method === 'POST' && request.url.endsWith('/orders')) { + drop = false + throw new Error('response lost after commit') + } + return result + }) + const disconnected = new PetEconomy(interrupted, f.journal, binding, sourceId, () => {}) + t.after(() => disconnected.dispose()) + const command = { type: 'buy', productId: 'food-snack', quantity: 2 } + await assert.rejects(disconnected.purchase(command, 'pet-uncertain-order'), /response lost/) + const afterDebit = (await f.sdk.me()).available + assert.equal((await f.sdk.orders()).length, 1) + assert.equal(f.doc.read().foodInventory['food-snack'], 3) + disconnected.dispose() + const reload = new PetEconomy(f.sdk, new PetEconomyJournal(f.doc.storage, f.doc.backup), binding, sourceId, () => {}) + t.after(() => reload.dispose()) + f.doc.fail(true) + await assert.rejects(reload.refresh(), /local write unavailable/) + assert.equal((await f.sdk.me()).available, afterDebit) + f.doc.fail(false) + await reload.purchase(command, 'new-ui-retry-key') // resumes saved key, not a new charge + assert.equal(f.doc.read().foodInventory['food-snack'], 5) + assert.equal(f.doc.read().economy.pending, undefined) + assert.equal((await f.sdk.me()).available, afterDebit) + assert.equal((await f.sdk.orders()).length, 1) + await reload.refresh() + assert.equal(f.doc.read().foodInventory['food-snack'], 5) +}) +test('unapproved or wrong-account migration keeps original backup and authority pending', async t => { + const f = await fixture(t, { approve: false }) + await assert.rejects(f.pet.migrate(), error => error.code === 'NOT_FOUND') + assert.equal(f.doc.read().economy.migration.status, 'pending') + assert.equal(f.doc.backups.size, 1) + assert.equal((await f.sdk.me()).available, 0) + const migration = f.doc.read().economy.migration + f.serverEconomy.commerce.entitlement(binding.instanceId, sourceId, migration.snapshotHash, 'alice', 100) + const wrong = new PetEconomy(f.bob, f.journal, binding, sourceId, () => {}) + t.after(() => wrong.dispose()) + await assert.rejects(wrong.refresh(), /身份/) + await assert.rejects( + f.bob.claim({ sourceId, entitlementId: migration.snapshotHash }, migration.key), + error => error.code === 'NOT_FOUND', + ) + await f.pet.refresh() + assert.equal(f.doc.read().economy.migration.status, 'complete') + assert.equal((await f.sdk.me()).available, 100) +}) +test('zero old balance binds without a cloud grant and never claims unknown usage as assets', async t => { + const f = await fixture(t, { initial: createPetState(), approve: false }) + await f.pet.migrate() + assert.match(f.doc.read().economy.migration.receiptId, /^zero-balance:/) + assert.equal((await f.sdk.me()).available, 0) + assert.equal((await f.sdk.ledger()).length, 0) + await f.pet.refresh() + assert.equal((await f.sdk.me()).available, 0) +}) +test('price disagreement fails before debit; disposal retains pending order for the next generation', async t => { + const f = await fixture(t) + await f.pet.migrate() + await assert.rejects( + f.sdk.purchase({ itemId: 'food-snack', quantity: 1, expectedTotal: 999 }, 'wrong-price-test'), + error => error.code === 'PRICE_CHANGED', + ) + assert.equal((await f.sdk.orders()).length, 0) + let coordinator + const transport = bearerTransport(fetch, () => f.tokens.alice) + const sdk = new EconomyClient(f.baseUrl, async request => { + const result = await transport(request) + if (request.method === 'POST' && request.url.endsWith('/orders')) coordinator.dispose() + return result + }) + coordinator = new PetEconomy(sdk, f.journal, binding, sourceId, () => {}) + await assert.rejects(coordinator.purchase({ type: 'buy', productId: 'food-snack' }, 'disposed-order'), /关闭/) + assert.ok(f.doc.read().economy.pending) + await f.pet.refresh() + assert.equal(f.doc.read().foodInventory['food-snack'], 4) + assert.equal((await f.sdk.orders()).length, 1) +}) +test('published Pet catalogue is the complete generated owner catalogue', async () => { + const exported = JSON.parse(await readFile('economy/pet-catalog.json', 'utf8')) + assert.deepEqual(exported, petMerchantCatalog()) +}) + +test('free Pet SKU produces one durable receipt without changing the shared balance', async t => { + const f = await fixture(t) + await f.pet.migrate() + const free = petMerchantCatalog().find(item => item.price === 0) + const before = await f.sdk.me() + const order = await f.sdk.purchase({ itemId: free.id, quantity: 1, expectedTotal: 0 }, 'free-pet-order') + assert.equal(order.total, 0) + assert.deepEqual(await f.sdk.purchase({ itemId: free.id, quantity: 1, expectedTotal: 0 }, 'free-pet-order'), order) + assert.deepEqual(await f.sdk.me(), before) + assert.equal((await f.sdk.orders()).length, 1) +}) + +test('a new Pet installation cannot replay an order belonging to the original store identity', async t => { + const f = await fixture(t) + await f.pet.migrate() + const command = { type: 'buy', productId: 'food-snack' } + await f.pet.purchase(command, 'original-store-order') + const originalStore = f.doc.read().economy.storeId + const order = (await f.sdk.orders())[0] + assert.deepEqual(order.fulfillmentTarget, { namespace: 'pet', storeId: originalStore }) + const newDoc = documentStore(oldWallet(100)) + const fresh = new PetEconomy(f.sdk, new PetEconomyJournal(newDoc.storage, newDoc.backup), binding, sourceId, () => {}) + t.after(() => fresh.dispose()) + const before = (await f.sdk.me()).available + await fresh.migrate() // same original digest/key is a historical replay, not another import + assert.notEqual(newDoc.read().economy.storeId, originalStore) + assert.equal((await f.sdk.me()).available, before) + await assert.rejects(fresh.purchase(command, 'original-store-order'), error => error.code === 'IDEMPOTENCY_CONFLICT') + assert.equal(newDoc.read().foodInventory['food-snack'], 3) + assert.equal(newDoc.read().economy.receipts.length, 0) + assert.equal((await f.sdk.orders()).length, 1) +}) + +test('endpoint identity cannot change even if another URL returns the same named account and instance', async t => { + const f = await fixture(t) + await f.pet.migrate() + const transport = bearerTransport(fetch, () => f.tokens.alice) + const alias = new EconomyClient( + f.baseUrl + '/alias', + request => transport({ ...request, url: request.url.replace('/alias/v1', '/v1') }), + ) + const changed = new PetEconomy(alias, f.journal, binding, sourceId, () => {}) + t.after(() => changed.dispose()) + await assert.rejects(changed.purchase({ type: 'buy', productId: 'food-snack' }, 'changed-endpoint'), /地址/) + assert.equal(f.doc.read().economy.pending, undefined) + assert.equal((await f.sdk.orders()).length, 0) +}) + +test('a charged order with a mismatched authenticated target never releases local fulfilment', async t => { + const f = await fixture(t) + await f.pet.migrate() + const transport = bearerTransport(fetch, () => f.tokens.alice) + const altered = new EconomyClient(f.baseUrl, async request => { + const response = await transport(request) + if (request.method === 'GET' && /\/orders\//.test(request.url)) { + const body = JSON.parse(response.body) + return { + ...response, + body: JSON.stringify({ ...body, fulfillmentTarget: { namespace: 'pet', storeId: 'different-store' } }), + } + } + return response + }) + const guarded = new PetEconomy(altered, f.journal, binding, sourceId, () => {}) + t.after(() => guarded.dispose()) + await assert.rejects(guarded.purchase({ type: 'buy', productId: 'food-snack' }, 'target-check-order'), /收据/) + assert.equal(f.doc.read().foodInventory['food-snack'], 3) + assert.ok(f.doc.read().economy.pending) + await f.pet.refresh() + assert.equal(f.doc.read().foodInventory['food-snack'], 4) + assert.equal((await f.sdk.orders()).length, 1) +}) + +test('a rejected request cannot erase the journal while another window commits that same key', async t => { + const f = await fixture(t, { initial: createPetState(), approve: false }) + await f.pet.migrate() + const transport = bearerTransport(fetch, () => f.tokens.alice) + let committed, release, recovery + const afterCommit = new Promise(resolve => { + committed = resolve + }) + const heldResponse = new Promise(resolve => { + release = resolve + }) + const recoveringSdk = new EconomyClient(f.baseUrl, async request => { + const response = await transport(request) + if (request.method === 'POST' && request.url.endsWith('/orders')) { + committed() + await heldResponse + } + return response + }) + const recovering = new PetEconomy(recoveringSdk, f.journal, binding, sourceId, () => {}) + t.after(() => { + recovering.dispose() + release() + }) + const failedSdk = new EconomyClient(f.baseUrl, async request => { + const response = await transport(request) + if (request.method === 'POST' && request.url.endsWith('/orders') && response.status === 409) { + await f.sponsor.grant({ + sourceId: 'test-initial', + accountId: 'alice', + eventId: 'concurrent-test-funding', + amount: 10, + }, 'concurrent-fund-key') + recovery = recovering.refresh() + await afterCommit + } + return response + }) + const failed = new PetEconomy(failedSdk, f.journal, binding, sourceId, () => {}) + t.after(() => failed.dispose()) + await assert.rejects( + failed.purchase({ type: 'buy', productId: 'food-snack' }, 'concurrent-recovery-order'), + error => error.code === 'INSUFFICIENT_FUNDS', + ) + assert.ok(f.doc.read().economy.pending) + release() + await recovery + assert.equal(f.doc.read().foodInventory['food-snack'], 4) + assert.equal((await f.sdk.orders()).length, 1) +}) diff --git a/test/pet-economy-journal.mjs b/test/pet-economy-journal.mjs new file mode 100644 index 0000000..86e387c --- /dev/null +++ b/test/pet-economy-journal.mjs @@ -0,0 +1,183 @@ +import { build } from 'esbuild' +import assert from 'node:assert/strict' +import test from 'node:test' +const bundle = await build({ + stdin: { + contents: + `export * from './src/pet-domain.ts'; export * from './src/pet-store.ts'; export * from './src/pet-economy-journal.ts'; export * from './src/pet-purchase.ts'`, + resolveDir: process.cwd(), + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, +}) +const { + createPetState, + migratePetState, + settlePetUsage, + applyPetCommand, + PetStore, + PetEconomyJournal, + petSnapshotHash, + petMerchantCatalog, +} = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`) +const binding = { instanceId: 'isolated-test-instance', accountId: 'alice' } +function fixture(initial = createPetState()) { + let value = structuredClone(initial), revision = 1, fail = false + const files = new Map() + const storage = { + load: async () => ({ value: structuredClone(value), revision: String(revision) }), + compareAndSwap: async (expected, next) => { + if (fail) throw new Error('offline local storage') + if (expected !== String(revision)) return false + value = structuredClone(next) + revision++ + return true + }, + } + const backups = { + create: async (id, backup) => { + if (!files.has(id)) files.set(id, structuredClone(backup)) + }, + load: async id => structuredClone(files.get(id)), + } + return { + storage, + backups, + files, + read: () => structuredClone(value), + fail: next => { + fail = next + }, + journal: new PetEconomyJournal(storage, backups, () => 1000), + } +} +function legacy() { + let state = + settlePetUsage(createPetState(), { sourceId: 'historical', totalTokens: 0 }, { key: 'base', now: 0 }).state + state = settlePetUsage(state, { sourceId: 'historical', totalTokens: 10_000_000 }, { key: 'income', now: 1 }).state + state = applyPetCommand(state, { type: 'buy', productId: 'item-auto-feeder' }, { key: 'device', now: 2 }).state + state = applyPetCommand(state, { type: 'rename', petId: 'pet:cat', name: '保留名字' }, { key: 'name', now: 3 }).state + return state +} +async function migrate(f) { + const pending = await f.journal.prepareMigration(binding, 'pet-approved-v1', 'https://economy.test/v1') + await f.journal.completeMigration(binding, pending.economy.migration.key, 'isolated-import-receipt') +} +function receipt(state, orderId = 'server-order-1') { + const { key, command, itemId, quantity, total, at } = state.economy.pending + return { key, command, itemId, quantity, total, at, orderId } +} +test('migration preserves the exact raw document in a create-only verified backup', async () => { + const original = legacy() + delete original.pets[0].sex // early v1 compatibility must not rewrite the backup + const f = fixture(original) + const pending = await f.journal.prepareMigration(binding, 'pet-approved-v1', 'https://economy.test/v1') + assert.equal(pending.version, 2) + assert.equal(pending.economy.migration.status, 'pending') + assert.deepEqual(f.files.get(pending.economy.migration.backupDocumentId).value, original) + assert.equal(pending.economy.migration.snapshotHash, await petSnapshotHash(original)) + assert.deepEqual(pending.wallet, original.wallet) + const store = new PetStore(f.storage) + await assert.rejects(store.execute({ type: 'feed', petId: 'pet:cat', foodId: 'food-snack' }, 'frozen'), /待对账/) + await f.journal.completeMigration(binding, pending.economy.migration.key, 'approved-receipt') + const actual = f.read() + delete actual.economy + actual.version = 1 + assert.deepEqual(actual, migratePetState(original)) + await assert.rejects( + f.journal.prepareMigration({ ...binding, accountId: 'mallory' }, 'pet-approved-v1', 'https://economy.test/v1'), + /不匹配/, + ) + await assert.rejects(f.journal.prepareMigration(binding, 'another-source', 'https://economy.test/v1'), /重复导入/) + const repeated = await f.journal.prepareMigration(binding, 'pet-approved-v1', 'https://economy.test/v1') + assert.equal(repeated.economy.migration.receiptId, 'approved-receipt') + assert.equal(f.files.size, 1) +}) +test('corrupt legacy economy or corrupt backup refuses authority switch without replacing data', async () => { + const original = legacy() + original.wallet.balance++ + const invalid = fixture(original) + await assert.rejects(invalid.journal.prepareMigration(binding, 'source', 'https://economy.test/v1'), /账目/) + assert.deepEqual(invalid.read(), original) + assert.equal(invalid.files.size, 0) + const f = fixture(legacy()) + f.backups.load = async () => ({ snapshotHash: 'f'.repeat(64), value: {} }) + await assert.rejects(f.journal.prepareMigration(binding, 'source', 'https://economy.test/v1'), /备份校验失败/) + assert.equal(f.read().version, 1) +}) +test('purchase is journaled before remote work, failed local delivery resumes after reload exactly once', async () => { + const f = fixture(legacy()) + await migrate(f) + const wallet = f.read().wallet, initial = f.read().foodInventory['food-snack'] + const state = await f.journal.preparePurchase( + binding, + { type: 'buy', productId: 'food-snack', quantity: 3 }, + 'purchase-1', + ) + const delivered = receipt(state) + assert.equal(f.read().foodInventory['food-snack'], initial) + f.fail(true) + await assert.rejects(f.journal.completePurchase(binding, delivered), /offline/) + assert.equal(f.read().economy.pending.key, 'purchase-1') + f.fail(false) + const reload = new PetEconomyJournal(f.storage, f.backups) + await Promise.all([reload.completePurchase(binding, delivered), f.journal.completePurchase(binding, delivered)]) + assert.equal(f.read().foodInventory['food-snack'], initial + 3) + assert.equal(f.read().economy.receipts.length, 1) + assert.equal(f.read().economy.pending, undefined) + assert.deepEqual(f.read().wallet, wallet) + assert.deepEqual(migratePetState(f.read()), f.read()) + await assert.rejects(reload.completePurchase(binding, { ...delivered, total: 999 }), /不一致/) +}) +test('pending purchase blocks competing local changes and shared mode cannot spend legacy balance', async () => { + const f = fixture(legacy()) + await migrate(f) + const store = new PetStore(f.storage, { trustedUsageEnabled: true }) + await assert.rejects(store.execute({ type: 'buy', productId: 'food-snack' }, 'bypass'), /共享购买/) + await assert.rejects(store.settleUsage({ sourceId: 'historical', totalTokens: 99_000_000 }, 'mint'), /铸币/) + await f.journal.preparePurchase(binding, { type: 'adopt', species: 'cat' }, 'adopt-1') + await assert.rejects(store.execute({ type: 'carePulse', elapsedMs: 60000 }, 'pulse'), /待对账/) + await assert.rejects(f.journal.preparePurchase(binding, { type: 'adopt', species: 'cat' }, 'adopt-2'), /上一笔/) + await f.journal.completePurchase(binding, receipt(f.read())) + assert.equal(f.read().pets.length, 2) + assert.equal(f.read().pets[1].id, 'pet:cat:2') +}) +test('permanent goods and upgrades preflight before an intent exists; catalogue includes all SKUs', async () => { + const f = fixture(legacy()) + await migrate(f) + await assert.rejects( + f.journal.preparePurchase(binding, { type: 'buy', productId: 'item-auto-feeder' }, 'duplicate'), + /已拥有/, + ) + assert.equal(f.read().economy.pending, undefined) + const state = await f.journal.preparePurchase(binding, { type: 'device-upgrade', device: 'feeder' }, 'upgrade-1') + assert.equal(state.economy.pending.itemId, 'upgrade-feeder-2') + await f.journal.completePurchase(binding, receipt(state)) + assert.equal(f.read().devices.feederLevel, 2) + assert.equal(f.read().devices.foodQueue.length, 0) + const catalog = petMerchantCatalog() + assert.ok(catalog.length > 200) + assert.equal(new Set(catalog.map(item => item.id)).size, catalog.length) + assert.ok(catalog.every(item => Number.isSafeInteger(item.price) && item.price >= 0 && item.namespace === 'pet')) +}) +test('oversized fulfilment rejects before journaling and wrong receipts never release the lock', async () => { + const f = fixture(legacy()) + await migrate(f) + await f.journal.preparePurchase(binding, { type: 'buy', productId: 'food-snack' }, 'purchase') + const valid = receipt(f.read()) + await assert.rejects(f.journal.completePurchase(binding, { ...valid, itemId: 'food-chicken' }), /不一致/) + await assert.rejects(f.journal.completePurchase({ ...binding, instanceId: 'another' }, valid), /不匹配/) + assert.equal(f.read().economy.pending.key, 'purchase') + const large = legacy() + large.receipts.push( + ...Array.from( + { length: 2000 }, + (_, i) => ({ key: `old-${i}`, kind: 'feed', at: i, coins: 0, detail: 'x'.repeat(256) }), + ), + ) + const oversized = fixture(large) + await assert.rejects(oversized.journal.prepareMigration(binding, 'source', 'https://economy.test/v1'), /空间不足/) + assert.equal(oversized.files.size, 0) +}) diff --git a/test/pet-pages.mjs b/test/pet-pages.mjs index 3c946f6..b232d91 100644 --- a/test/pet-pages.mjs +++ b/test/pet-pages.mjs @@ -33,12 +33,13 @@ test('shop renders real avatar definitions and unavailable income without offeri assert.match(html, /宠物币不足/) assert.doesNotMatch(html, /充值|领取宠物币|测试余额/) }) -test('wallet distinguishes permission denial from connected partial coverage', () => { +test('wallet distinguishes permission denial and attributed work without promising unconfigured backpay', () => { const denied = render('ledger', createPetState(), false, { status: 'unavailable', reason: 'permission-denied' }) assert.match(denied, /使用奖励未开启/) const ready = render('ledger', createPetState(), false, { status: 'ready', coverage: 'partial', observedThrough: 1000, eligibleTokens: 100000 }) - assert.match(ready, /不包含全部历史或其他设备/) - assert.match(ready, /最近同步/) + assert.match(ready, /仅统计可归因的正常根任务工作/) + assert.match(ready, /游戏、分叉、子任务和未知来源不计入/) + assert.match(ready, /期间用量不补发/) assert.doesNotMatch(ready, /奖励暂不可用/) }) test('overview keeps care forms in a secondary page and bag shows owned inventory', () => { diff --git a/test/pet-usage.mjs b/test/pet-usage.mjs index 7230d5d..ea04aeb 100644 --- a/test/pet-usage.mjs +++ b/test/pet-usage.mjs @@ -26,52 +26,26 @@ function service(initial = snapshot()) { } } function controller(source, store, statuses = []) { - return new PetUsageController(source, async (usage, key, current) => { await store.settleUsage(usage, key, current) }, value => statuses.push(value)) + return new PetUsageController(source, value => statuses.push(value)) } -test('public usage first read baselines, later fractional deltas award once, unchanged polling writes nothing', async t => { - const adapter = memory(), store = new PetStore(adapter, { trustedUsageEnabled: true }), source = service(snapshot(9999)), statuses = [] - const client = controller(source, store, statuses); t.after(() => client.dispose()) +test('partial profile aggregates never mint, queue rewards, or change the legacy frontier', async t => { + const initial = createPetState(), adapter = memory(initial), source = service(snapshot(9999)), statuses = [] + const client = controller(source, new PetStore(adapter, { trustedUsageEnabled: true }), statuses) + t.after(() => client.dispose()) await client.start() - assert.equal(adapter.state().wallet.balance, 0) - const writes = adapter.writes() - for (let index = 0; index < 20; index++) await client.refresh() - assert.equal(adapter.writes(), writes) - assert.equal(statuses.filter(value => value.status === 'initializing').length, 1) - source.set(snapshot(10000, 1)); await client.refresh() - assert.equal(adapter.state().wallet.balance, 0) - source.set(snapshot(19999, 2)); await client.refresh() - assert.equal(adapter.state().wallet.balance, 1) - assert.equal(adapter.state().receipts.length, 1) - assert.equal(statuses.at(-1).status, 'ready') - assert.equal(statuses.at(-1).coverage, 'partial') -}) -test('two clients share a frontier, restart does not remint, and a new epoch never credits history', async t => { - const adapter = memory(), source = service(), a = controller(source, new PetStore(adapter, { trustedUsageEnabled: true })), b = controller(source, new PetStore(adapter, { trustedUsageEnabled: true })) - t.after(() => { a.dispose(); b.dispose() }) - await Promise.all([a.start(), b.start()]) - source.set(snapshot(20000, 1)); await Promise.all([a.refresh(), b.refresh()]) - assert.equal(adapter.state().wallet.balance, 2) - a.dispose() - const restarted = controller(source, new PetStore(adapter, { trustedUsageEnabled: true })); t.after(() => restarted.dispose()) - await restarted.start() - assert.equal(adapter.state().wallet.balance, 2) - source.set(snapshot(1000000, 50, 'epoch-2')); await restarted.refresh() - assert.equal(adapter.state().wallet.balance, 2) - source.set(snapshot(1010000, 51, 'epoch-2')); await restarted.refresh() - assert.equal(adapter.state().wallet.balance, 3) -}) -test('rollback, same revision with changed counters and malformed public totals are unavailable without credit', async t => { - const adapter = memory(), source = service(), statuses = [], client = controller(source, new PetStore(adapter, { trustedUsageEnabled: true }), statuses) - t.after(() => client.dispose()); await client.start() - source.set(snapshot(20000, 2)); await client.refresh() - const writes = adapter.writes() - for (const value of [snapshot(10000, 1), snapshot(30000, 2), { ...snapshot(30000, 3), inputTokens: 1 }]) { + for (const value of [snapshot(10000, 1), snapshot(1_000_000, 2), snapshot(2_000_000, 3, 'new-epoch')]) { source.set(value); await client.refresh() - assert.equal(statuses.at(-1).status, 'unavailable') - assert.equal(adapter.writes(), writes) - assert.equal(adapter.state().wallet.balance, 2) + assert.deepEqual(statuses.at(-1), { status: 'unavailable', reason: 'usage-attribution-unavailable' }) + assert.equal(adapter.writes(), 0) + assert.deepEqual(adapter.state(), initial) } }) +test('malformed aggregate remains unavailable without invoking settlement', async t => { + const source = service({ ...snapshot(), inputTokens: 1 }), statuses = [] + const client = new PetUsageController(source, status => statuses.push(status)) + t.after(() => client.dispose()); await client.start() + assert.equal(statuses.at(-1).reason, 'invalid-snapshot') +}) test('permission invalidation fences an in-flight ready response and publishes denied status', async t => { const adapter = memory(), source = service(), statuses = [] let release @@ -87,19 +61,15 @@ test('permission invalidation fences an in-flight ready response and publishes d assert.equal(adapter.writes(), 0) assert.deepEqual(statuses.at(-1), { status: 'unavailable', reason: 'permission-denied' }) }) -test('disposal while owner storage load is pending fences CAS and unsubscribes', async () => { - const adapter = memory(), source = service(), statuses = [] - let entered, release - const waiting = new Promise(resolve => { entered = resolve }) - const gate = new Promise(resolve => { release = resolve }) - const load = adapter.load - adapter.load = async () => { entered(); await gate; return load() } - const client = controller(source, new PetStore(adapter, { trustedUsageEnabled: true }), statuses) - const starting = client.start(); await waiting - client.dispose(); release(); await starting - assert.equal(adapter.writes(), 0) +test('disposal during a public read fences publication and unsubscribes', async () => { + let release + const source = service(), statuses = [] + source.read = () => new Promise(resolve => { release = resolve }) + const client = new PetUsageController(source, status => statuses.push(status)) + const starting = client.start() + client.dispose(); release(snapshot(100000, 20)); await starting assert.equal(source.listeners(), 0) - assert.equal(statuses.at(-1).status, 'initializing') + assert.deepEqual(statuses, [{ status: 'initializing' }]) }) test('thousands of usage increments keep a compact permanent frontier and one income record', () => { let state = settlePetUsage(createPetState(), { sourceId: 'host', totalTokens: 0, revision: 0 }, { key: 'baseline', now: 0 }).state diff --git a/test/pet-work-rewards.mjs b/test/pet-work-rewards.mjs new file mode 100644 index 0000000..0418c3b --- /dev/null +++ b/test/pet-work-rewards.mjs @@ -0,0 +1,311 @@ +import { bearerTransport, EconomyClient } from '@cordisx/economy/client' +import { createEconomyServer, Economy } from '@cordisx/economy/server' +import { build } from 'esbuild' +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +const bundle = await build({ + stdin: { + contents: + `export * from './src/pet-work-rewards.ts'; export * from './src/pet-usage.ts'; export * from './src/pet-work-sponsor.ts';`, + resolveDir: process.cwd(), + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, +}) +const { PetWorkRewards, PetUsageController, readyWork, petWorkSponsorConnector } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}` +) +const snapshot = (eligibleTokens = 0, revision = 0, epoch = 'work-v2') => ({ + schemaVersion: 2, + status: 'ready', + policyId: 'codex-local-work-input-output-v2', + classification: { + version: 'host-game-cwd-v1', + hostGameTasks: 'excluded', + forksAndSubagents: 'excluded', + unknownSources: 'excluded', + }, + scopeId: 'test-profile', + sourceId: 'test-work', + epoch, + revision, + eligibleTokens, + inputTokens: eligibleTokens, + outputTokens: 0, + enabledAt: 1, + observedThrough: 1000 + revision, + coverage: 'partial', + diagnostics: [], +}) +function memory() { + let value = null, revision = 0, fail = false + return { + load: async () => ({ value: structuredClone(value), revision }), + save: async (expected, next) => { + if (fail) throw new Error('isolated local CAS unavailable') + if (expected !== revision) return false + value = structuredClone(next) + revision++ + return true + }, + read: () => structuredClone(value), + fail: next => { + fail = next + }, + } +} +async function fixture(t, { daily = 100, budget = 100, storage = memory() } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'pet-work-test-')) + const economy = new Economy(join(dir, 'ledger.sqlite')) + economy.auth.createInstance('work-test', 1000) + economy.auth.createAccount('work-test', 'alice') + const userToken = economy.auth.login(economy.auth.enrollment('work-test', 'alice')).token + const token = economy.auth.createService('work-test', 'pet-sponsor', '*', 100).token + economy.commerce.createSource('work-test', 'pet-work', 'pet-sponsor', 'reward', budget, daily, daily) + const server = createEconomyServer(economy) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const baseUrl = `http://127.0.0.1:${server.address().port}`, transport = bearerTransport(fetch, () => token) + const sdk = new EconomyClient(baseUrl, transport), + user = new EconomyClient(baseUrl, bearerTransport(fetch, () => userToken)) + const identity = { baseUrl, instanceId: 'work-test', accountId: 'alice', sourceId: 'pet-work' } + const statuses = [], rewards = new PetWorkRewards(storage, s => statuses.push(s)) + const sponsor = { sdk, identity, dispose() {} } + rewards.connect(sponsor) + t.after(async () => { + rewards.dispose() + await new Promise(r => server.close(r)) + economy.close() + await rm(dir, { recursive: true, force: true }) + }) + return { + economy, + sdk, + user, + identity, + storage, + statuses, + rewards, + sponsor, + transport, + baseUrl, + observe: (s, valid = () => true) => rewards.observe(s, identity, valid), + } +} +test('v2 exact attribution uses a fresh baseline and original 10000:1 rate, preserving fractional future work', async t => { + const f = await fixture(t) + await f.observe(snapshot(900000, 10)) + assert.equal((await f.user.me()).available, 0) + await f.observe(snapshot(909999, 11)) + assert.equal((await f.user.me()).available, 0) + await f.observe(snapshot(910000, 12)) + assert.equal((await f.user.me()).available, 1) + await f.observe(snapshot(940001, 13)) + assert.equal((await f.user.me()).available, 4) + await f.observe(snapshot(940001, 13)) + assert.equal((await f.user.me()).available, 4) + assert.equal(f.storage.read().remainder, 1) + assert.equal(f.storage.read().earned, 4) + f.economy.store.assertConservation('work-test') +}) +test('missing sponsor, permission gap, new epoch and lower snapshots never backpay old usage', async t => { + const f = await fixture(t), statuses = [] + const unconfigured = new PetWorkRewards(f.storage, s => statuses.push(s)) + await unconfigured.observe(snapshot(100000), f.identity, () => true) + assert.equal(f.storage.read(), null) + assert.equal(statuses.at(-1).status, 'unavailable') + await f.observe(snapshot(100000, 1)) + f.rewards.pause() + await f.observe(snapshot(900000, 2)) + assert.equal((await f.user.me()).available, 0) + await f.observe(snapshot(910000, 3)) + assert.equal((await f.user.me()).available, 1) + await f.observe(snapshot(1, 0)) + assert.equal(f.storage.read().cursor.tokens, 910000) + await f.observe(snapshot(1000000, 4)) + assert.equal((await f.user.me()).available, 1) + await f.observe(snapshot(5000000, 5, 'new-epoch')) + assert.equal((await f.user.me()).available, 1) + await f.observe(snapshot(5010000, 6, 'new-epoch')) + assert.equal((await f.user.me()).available, 2) + unconfigured.dispose() +}) +test('source or daily budget exhaustion advances frontier without creating a pending debt', async t => { + const f = await fixture(t, { daily: 1 }) + await f.observe(snapshot()) + await f.observe(snapshot(20000, 1)) + assert.equal((await f.user.me()).available, 0) + assert.equal(f.storage.read().pending, undefined) + assert.match(f.statuses.at(-1).reason, /额度不足/) + await f.observe(snapshot(30000, 2)) + assert.equal((await f.user.me()).available, 1) + await f.observe(snapshot(40000, 3)) + assert.equal((await f.user.me()).available, 1) + assert.equal(f.storage.read().cursor.tokens, 40000) +}) +test('response loss persists original event, reload replays once, and unresolved-period work is dropped', async t => { + const f = await fixture(t) + let drop = true + const sdk = new EconomyClient(f.baseUrl, async request => { + const result = await f.transport(request) + if (drop && request.url.endsWith('/rewards/grant')) { + drop = false + throw new Error('response lost') + } + return result + }) + f.rewards.connect({ ...f.sponsor, sdk }) + await f.observe(snapshot()) + await f.observe(snapshot(20000, 1)) + const event = f.storage.read().pending.eventId + assert.equal((await f.user.me()).available, 2) + f.rewards.dispose() + const reloaded = new PetWorkRewards(f.storage, () => {}) + reloaded.connect(f.sponsor) + t.after(() => reloaded.dispose()) + await reloaded.observe(snapshot(500000, 2), f.identity, () => true) + assert.equal((await f.user.me()).available, 2) + assert.equal(f.storage.read().lastEventId, event) + await reloaded.observe(snapshot(600000, 3), f.identity, () => true) + assert.equal((await f.user.me()).available, 2) + await reloaded.observe(snapshot(610000, 4), f.identity, () => true) + assert.equal((await f.user.me()).available, 3) +}) +test('failed local receipt write recovers through same event without repeating cloud credit', async t => { + const f = await fixture(t) + const sdk = new EconomyClient(f.baseUrl, async request => { + const result = await f.transport(request) + if (request.url.endsWith('/rewards/grant')) f.storage.fail(true) + return result + }) + f.rewards.connect({ ...f.sponsor, sdk }) + await f.observe(snapshot()) + await f.observe(snapshot(10000, 1)) + assert.ok(f.storage.read().pending) + assert.equal((await f.user.me()).available, 1) + f.storage.fail(false) + f.rewards.connect(f.sponsor) + await f.observe(snapshot(10000, 1)) + assert.equal((await f.user.me()).available, 1) + assert.equal(f.storage.read().pending, undefined) +}) +test('multi-window CAS and successful event deduplication grant one reward for one interval', async t => { + const f = await fixture(t), second = new PetWorkRewards(f.storage, () => {}) + second.connect(f.sponsor) + t.after(() => second.dispose()) + await f.observe(snapshot()) + await second.observe(snapshot(), f.identity, () => true) + await Promise.all([f.observe(snapshot(10000, 1)), second.observe(snapshot(10000, 1), f.identity, () => true)]) + assert.equal((await f.user.me()).available, 1) + assert.equal(f.storage.read().earned, 1) +}) +test('wrong wallet identity and user sponsor cannot grant or overwrite reward history', async t => { + const f = await fixture(t) + await f.observe(snapshot()) + const before = f.storage.read() + await f.rewards.observe(snapshot(10000, 1), { ...f.identity, accountId: 'bob' }, () => true) + assert.deepEqual(f.storage.read(), before) + f.rewards.connect({ ...f.sponsor, sdk: f.user }) + await f.observe(snapshot(10000, 1)) + assert.equal((await f.user.me()).available, 0) + assert.deepEqual(f.storage.read(), before) +}) +test('disposal or invalidated generation during source read prevents grant and cursor writes', async t => { + const f = await fixture(t) + await f.observe(snapshot()) + let release, entered + const held = new Promise(r => { + release = r + }), + start = new Promise(r => { + entered = r + }) + const sdk = new EconomyClient(f.baseUrl, async request => { + entered() + await held + return f.transport(request) + }) + f.rewards.connect({ ...f.sponsor, sdk }) + const before = f.storage.read() + const running = f.observe(snapshot(10000, 1)) + await start + f.rewards.dispose() + release() + await running + assert.deepEqual(f.storage.read(), before) + assert.equal((await f.user.me()).available, 0) +}) +test('public controller accepts only exact work classification and never calls profile v1 when v2 is present', async () => { + let value = snapshot(), calls = 0, pauses = 0 + const usage = { + read: () => { + throw new Error('v1 must not be read') + }, + readWork: async () => value, + subscribe: () => () => {}, + } + const controller = new PetUsageController(usage, () => {}, async () => { + calls++ + }, () => { + pauses++ + }) + await controller.start() + assert.equal(calls, 1) + for ( + const change of [ + { policyId: 'other' }, + { classification: { ...value.classification, unknownSources: 'included' } }, + { schemaVersion: 1 }, + { eligibleTokens: -1 }, + ] + ) { + value = { ...snapshot(), ...change } + assert.equal(readyWork(value), false) + await controller.refresh() + } + assert.equal(calls, 1) + assert.equal(pauses, 4) + controller.dispose() +}) +test('sponsor connection is separate opaque service authorization with identity-checked readonly preflight', async t => { + const f = await fixture(t), authorized = [], revoked = [] + const http = { + authorize: async request => { + authorized.push(request) + return { + status: 'accepted', + value: { + contract: 'cordisx.http-connection/v1', + id: 'opaque-sponsor', + origin: f.baseUrl, + credential: 'bearer', + }, + } + }, + revoke: async value => { + revoked.push(value) + }, + request: async request => { + const result = await f.transport({ + method: request.method, + url: f.baseUrl + request.path, + headers: request.headers, + body: request.body, + }) + return { status: 'accepted', value: { statusCode: result.status, body: result.body } } + }, + } + const connection = await petWorkSponsorConnector(http, 'pet-work')(f.identity) + assert.deepEqual(connection.identity, f.identity) + assert.equal(authorized.length, 1) + connection.dispose() + assert.equal(revoked.length, 1) + await assert.rejects(petWorkSponsorConnector(http, 'pet-work')({ ...f.identity, instanceId: 'wrong' }), /同一实例/) + assert.equal(revoked.length, 2) +})