From baf41de08356792ebeee2c563e397ff97621ed1d Mon Sep 17 00:00:00 2001 From: xxhhlk Date: Fri, 23 Oct 2026 04:08:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(loomy):=20=E5=AE=A0=E7=89=A9=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E6=89=93=E5=B7=A5=E5=A5=96=E5=8A=B1=E9=9A=8F=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E9=A2=86=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /pet-work 读当日快照、POST /pet-work/rewards 逐档申请未领档位;档位由 服务端按官方客户端的有效对话轮次计算,本包只收已达档位的分,未达档位如实上报。 - 快照的配置版本在 config.version,领奖请求体里却是 configVersion(实测差异), 两处都按服务端形状读,不硬编码。 - 额度卡片新增一行 aside 的只读进度(宠物打工 今日 300/1000 · 已领 1/3 档), 按账号缓存 10 分钟,封顶或活动关闭时不显示。 - 领奖只挂在 auth.checkin:未达档位是常态,usage 读一次就写一次只是白烧上游请求。 - 补离线测试(快照形状、逐档推进、封顶零请求、未达档位、100002、缓存失效), 并在 docs/vendors.md、docs/architecture.md、README、CHANGELOG 留下落点。 --- CHANGELOG.md | 16 ++ README.md | 5 +- docs/architecture.md | 4 + docs/vendors.md | 67 ++++++ src/vendors/loomy/api.ts | 147 ++++++++++++- src/vendors/loomy/index.ts | 17 +- src/vendors/loomy/petwork.ts | 298 +++++++++++++++++++++++++ src/vendors/loomy/product.ts | 25 +++ src/vendors/loomy/usage.ts | 28 ++- tests/loomy.petwork.test.ts | 406 +++++++++++++++++++++++++++++++++++ tests/loomy.plugin.test.ts | 115 ++++++++++ 11 files changed, 1119 insertions(+), 9 deletions(-) create mode 100644 src/vendors/loomy/petwork.ts create mode 100644 tests/loomy.petwork.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d37c3a..e4ce924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 本文件按 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的风格记录。 +## [Unreleased] + +### Added + +- **Loomy 的宠物每日打工奖励(0~1000 分/天)随签到领取**。`GET /pet-work` 读当日 + 快照、`POST /pet-work/rewards` 申请一档;档位由服务端按该账号当日在官方客户端的 + 有效对话轮次计算,本包只把已达档位的分收下来,未达档位时如实上报(不冒充领到)。 + 一次签到会把今天未领的档位逐档申请,某档没达标就停手。请求体的 `configVersion` + 取自快照的 `config.version` —— 两处拼写不同是服务端的形状(2026-10-23 实测); + 封顶或当天没有未领档位时一个写请求都不发。 +- 额度卡片新增一行 `aside` 的只读进度(`宠物打工 今日 300/1000 · 已领 1/3 档`), + 按账号缓存 10 分钟,封顶或活动关闭时不显示。 +- 领奖只挂在 `auth.checkin`(`Check in now` / 每日自动签到 / `magpie accounts + checkin`),**不**在 `usage` 里读到就领:未达档位是常态,每次额度刷新都发一个 + 注定被拒的请求只是白烧上游(新手任务能那样做,是因为服务端不校验前置行为)。 + ## [0.9.9] - 2026-10-22 ### Fixed diff --git a/README.md b/README.md index 88bc93f..3f922ad 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ magpie plugin options <插件名> '{"accessKeyId": "…", "accessKeySecret": " | 思考档位 | 远端 `supported_reasoning_levels` 原样(`none` / `high` / `max`);只声明远端下发过的档位 —— 上游对非法档位不报错、按最大档计费执行 | 厂商自己的档位(`none` / `low` / `medium` / `high` / `xhigh`)直接映射到宿主 variants | 只有「开 / 关」两态,宿主词汇用 `high` / `none`,在请求体里写成 `extra_body.thinking.type = enabled` / `disabled`(厂商唯一有效的通道;`reasoning_effort` 实测无效,因此插件不会把它当档位用) | 远端 `reasoning_effort_config.options` 的字符串原样作为档位(`light` / `high` / `extra_high` …);它们是上游 wire 值,不做白名单转换 | | 用量卡片 | `points/balance` 的权威合计 + 会员/购买/赠送/星火分池(各自带到期展示);无重置周期,全部 `aside`(只展示,不参与选号) | 三行:今日积分(参与选号)+ 永久积分(`aside`)+ 新手任务进度(`aside`,8/8 领完时不显示) | `GET /balance` 的 available / reward / daily / topup / monthly 分池展示;上限服务未公布,全部 `aside`(只展示,不参与选号) | `POST /pay/ide_user_ent_usage` 的**积分包**列表,剩余 = Σ(授予 − 已用),另加一行签到状态;服务从不公布账号级上限,全部 `aside` | | 锁定永久积分 | `options.lockPermanent: true` 时,今日额度用完即拒绝请求(429,宿主把账号 bench 到窗口重置),不再悄悄花永久积分 | — | — | -| 每日签到 | 有:`client-popups/pending` 空表即已领(零写请求),否则对每个可领弹窗 `complete`(每日签到 + 新手礼包一起领);登录成功后顺手领一次 | 有:`POST /points/first-login`(幂等,返回当日额度),并在同一次签到里顺手补领没领完的**新手任务**(8 项 / 10000 分,纯 API 直领、不花模型 token;领完的账号零额外请求) | 没有:每日 300 积分由服务端自动发放、没有端点,因此插件不做签到 | 有:先查 `checkin_credits/status`(已领就不发写请求),再 `checkin_credits/claim`;claim 响应不含积分数,成功后再查一次 status 取实际所得。签到按**设备**每日一次,`9074` 如实报失败、不换设备重试 | +| 每日签到 | 有:`client-popups/pending` 空表即已领(零写请求),否则对每个可领弹窗 `complete`(每日签到 + 新手礼包一起领);登录成功后顺手领一次 | 有:`POST /points/first-login`(幂等,返回当日额度),并在同一次签到里顺手补领没领完的**新手任务**(8 项 / 10000 分,纯 API 直领、不花模型 token;领完的账号零额外请求)与**宠物打工**奖励(每天 0~1000 分,档位按官方客户端的有效对话轮次算,未达档位如实上报) | 没有:每日 300 积分由服务端自动发放、没有端点,因此插件不做签到 | 有:先查 `checkin_credits/status`(已领就不发写请求),再 `checkin_credits/claim`;claim 响应不含积分数,成功后再查一次 status 取实际所得。签到按**设备**每日一次,`9074` 如实报失败、不换设备重试 | - **新手任务(Loomy)**:宿主没有一次性奖励钩子,也不给插件任何按钮,所以本包把领取 挂在两处:`auth.checkin`(`Check in now` / `magpie accounts checkin` / 每日自动签到 都会顺手领),以及 `auth.usage`——**卡片读到还有未完成就自动领掉**,用户不用点任何 @@ -421,7 +421,8 @@ bun run verify # 改动完成前必须全绿 - Raccoon 的「桌面端登录奖励」是三千里程碑式的一次性奖励,magpie 没有对应钩子(它既不是每日签到、也不该在后台偷偷代领),所以本包不实现;请到官方客户端领取。 - Loomy 的「新手任务」10000 分随每日签到一起领(宿主的 `auth.checkin` 是唯一写钩子;任务领取是纯 API 直领,服务端不校验前置行为)。已领完的账号不产生额外请求。 -- 额度读取默认只读,例外是 Loomy 的新手任务:`auth.usage` 读到还有未完成时会把它们领掉(宿主的卡片不给插件按钮,这是唯一入口;`options.claimTasks: false` 可关掉)。会写远端状态的调用:Loomy 的每日额度领取与新手任务补领(`auth.checkin` + `auth.usage`)、TRAE 的每日签到领取,以及 Raccoon / TRAE 的 token 续期。 +- Loomy 的「宠物打工」每天 0~1000 分,档位由服务端按**官方客户端的有效对话轮次**计算:本包只把已达档位的分收下来,未达档位时如实说「未达档位」,不冒充领到。额度卡片上有一行只读进度(`aside`),封顶或活动关闭时不显示。 +- 额度读取默认只读,例外是 Loomy 的新手任务:`auth.usage` 读到还有未完成时会把它们领掉(宿主的卡片不给插件按钮,这是唯一入口;`options.claimTasks: false` 可关掉)。会写远端状态的调用:Loomy 的每日额度领取、新手任务补领与宠物打工领奖(`auth.checkin`;新手任务另有 `auth.usage`)、TRAE 的每日签到领取,以及 Raccoon / TRAE 的 token 续期。 - 短信登录依赖厂商自己的验证码链路(Raccoon 的阿里云滑块脚本来自其 CDN),离线或脚本被拦截时只有扫码可用。 - TRAE 走的是逆向的私有接口,不是厂商公开 API;它的签到按**设备**每日一次(换账号不重置),失败时如实告知而不轮换设备重试。 - 三家厂商都还没在 magpie 内跑过真实登录与真实聊天;当前证据是 `plugin add` 级别的加载与形状检查。逐条未验证边界登记在 `docs/vendors.md` §5(Trae 另有 §5 第 11–15 条)。 diff --git a/docs/architecture.md b/docs/architecture.md index aea8f49..f0247ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -283,6 +283,10 @@ Loomy 的当日上限只有签到应答会说,因此签到与 usage 都把它 (`usage.ts` 的 `quotaForNow`);属于已经重置那一天的上限会被丢掉,不带到 新的一天。 +Loomy 的宠物打工奖励(`/pet-work`)**只挂在 `auth.checkin`**:档位由服务端按官方 +客户端的对话轮次算,本包不产生轮次归属,读一次就写一次只会白烧上游请求;卡片上那 +一行是只读的 `aside` 窗口(`petwork.ts`)。 + **续期**:只有 Raccoon 有(`auth.refresh` + `refreshLead`)。Loomy 没有续期端点, 到点由宿主标记失效、用户重新登录——不实现假的续期。 diff --git a/docs/vendors.md b/docs/vendors.md index 2563e2e..40f551e 100644 --- a/docs/vendors.md +++ b/docs/vendors.md @@ -138,6 +138,7 @@ metadata。适配器在插件加载时注册回退模型、V2 integration 登录 | 今日积分 | `dailyQuota − dailyConsumed`(`/points/records` 只给两个余额,`dailyQuota` 只有 `POST /points/first-login` 给) | 唯一参与路由的窗口:`used = consumed/quota`,重置时间取服务端 `dailyCycleDate`,缺失时用北京时间次日 00:05 | | 永久积分 | 同上 | `aside: true`:只展示,不参与路由 | | 新手任务 | `GET /onboarding/tasks` 的 8 个标记 | `aside: true`:只展示,不参与路由;8/8 领完时不显示这一行 | +| 宠物打工 | `GET /pet-work` 的 `status`(今日累计、已发档位、是否封顶) | `aside: true`:只展示,不参与路由;封顶或活动关闭时不显示这一行 | **`dailyQuota` 只有 `POST /points/first-login` 会说**(2026-10-15 实测: `/points/records` 只回 `balance` / `dailyBalance` / `availableBalance`,**没有** @@ -236,6 +237,72 @@ checkin`、Settings › Daily check-in › Plugins 的自动开关,三者都 - 领取是幂等的(已领完的账号 0 个请求);被拒时停下、记下已领的部分,等读数过期再试。 - `options.claimTasks: false` 关掉它,让 `usage` 回到严格只读。默认开启。 +### 宠物每日打工(0~1000 分,2026-10-23 接入) + +`GET /pet-work` 读当日快照,`POST /pet-work/rewards` 申请一档。这是 Loomy 唯一 +「按天算、要手动领」的积分:每天上限 1000 分,档位由服务端按**该账号当日在官方 +客户端的有效对话轮次**计算(`sessionWeight × sessionIndex + depthWeight × +depthIndex`,权重与基线都由快照的 `config` 下发,2026-10-23 读到 0.4 / 0.6、 +index 上限 2 / 2、采样 28 天、最少 7 个活跃日)。 + +**实测形状**(2026-10-23,真账号只读探测;成功码仍是字符串 `"000000"`): + +```json +{ "enabled": true, "businessDate": "2026-10-23", + "config": { "version": "pet-work-v1", "dailyMaxPoints": 1000, + "tiers": [{ "tierId": "tier_1", "score": 60 }, + { "tierId": "tier_2", "score": 100 }, + { "tierId": "tier_3", "score": 150 }] }, + "status": { "earnedPointsToday": 1000, + "rewardedTierIds": ["tier_1", "tier_2", "tier_3"], + "dailyRewardCapped": true, + "lastGrant": { "grantBatchId": "pwg_…", "grantedTierIds": ["tier_3"], + "grantedPoints": 500, "rewardedAt": 1792698335 } } } +``` + +⚠️ **配置版本两处拼写不同**:快照里是 `config.version`,领奖请求体里是 +`configVersion`。把快照里的键名原样塞进请求体会被服务端判过期。 + +**领奖请求**只有三个字段:`{ businessDate, configVersion, reachedTierId }`。 +`businessDate` 必须用快照里那个服务端业务日 —— 本机时钟猜的日期会被拒。 + +**回执**(同一账号当天已封顶时的实测,即「已领」形态): + +```json +{ "businessDate": "2026-10-23", "grantBatchId": "", "granted": false, + "duplicate": true, "grantedPoints": 0, "grantedTierIds": [], + "status": { "earnedPointsToday": 1000, "rewardedTierIds": ["tier_1", "tier_2", "tier_3"], + "dailyRewardCapped": true } } +``` + +**档位怎么推进。** 本包拿不到该账号的轮次统计,只能按「已发档位」推进:一次都没领 +就申请 `tier_1`(够不够由服务端判),已发 `tier_1` 就申请 `tier_2`,全发完不再申请。 +拿不到档位表时不猜更高档:`tier_1` 已发即视为完成。**一次签到会把今天未领的档位逐档 +申请**(用户当天在官方客户端聊够了却一直没按签到,一档都不会漏),某档没达标就停手 —— +档位按分数单调,这一档没到,更高的更不可能到;回执带回的 `grantedTierIds` 会并进 +「已领」,所以服务端若一次发多档也不会重复申请。 + +**为什么只挂在 `auth.checkin`。** 达标由服务端按官方客户端的轮次算,本包是网关、 +不产生轮次归属 —— 每次领奖请求对「够不够档」毫无影响,只在用户当天已经攒够时把分 +收下来。收益是用户的,动作就落在用户按得下去的地方(`Check in now` / 每日自动签到 / +`magpie accounts checkin`)。**不在 `usage` 里读到就领**:未达档位是常态,每次额度 +刷新都发一个注定被拒的 POST 只是白烧上游请求 —— 新手任务能那样做,是因为那 8 项 +服务端不校验前置行为、必然领得到。 + +**卡片上那一行**(`petwork.ts`,`aside: true`,按账号缓存 10 分钟): + +``` +宠物打工 今日 300/1000 · 已领 1/3 档 +宠物打工 今日 0/1000 · 未达档位 +``` + +封顶(`dailyRewardCapped`)或活动关闭时不显示 —— 与新手任务 8/8 时不显示同一取舍。 +不带 `limit` / `amount`,否则卡片会把它画成「池子被花掉多少」的进度条,并让它参与选号。 + +**未验证边界**:`granted: true` 的发放分支只在离线测试里按 `grantedPoints` 与 +「今日累计差分」两条路走过;真账号当天已封顶,只实测到 `duplicate: true` 这一种 +回执。首次真实领奖时核对 `grantedPoints` 是否等于 `earnedPointsToday` 的增量。 + ### 上下文窗口与图片能力的硬覆盖(2026-10-22 自 JetHub 移植) | 覆盖 | 模型 | 远端声明 | 真实值 | 证据 | diff --git a/src/vendors/loomy/api.ts b/src/vendors/loomy/api.ts index 627e7be..7742c67 100644 --- a/src/vendors/loomy/api.ts +++ b/src/vendors/loomy/api.ts @@ -15,7 +15,7 @@ import { errorMessage, SignInExpiredError, VendorError } from "../../core/errors.ts" import { type Fetcher, httpBytes, httpJson } from "../../core/http.ts" -import { asArray, asObject, num, str } from "../../core/json.ts" +import { asArray, asObject, bool, num, str, stringList } from "../../core/json.ts" import { randomId } from "../../core/util.ts" import { LOOMY_ACCOUNT_BASE, @@ -25,6 +25,7 @@ import { LOOMY_OK_CODE, LOOMY_ONBOARDING_TOTAL, LOOMY_PATHS, + LOOMY_PETWORK_FIRST_TIER, LOOMY_REQUEST_TIMEOUT_MS, LOOMY_SESSION_TTL_SECONDS, LOOMY_SIGNED_OUT_CODE, @@ -606,6 +607,150 @@ export async function claimKnownTasks( } } +// ---- 宠物每日打工(pet-work)------------------------------------------------- + +/** 快照里的一个档位;`score` 是拿到该档所需的档位分,不是积分额。 */ +export interface LoomyPetWorkTier { + id: string + score: number +} + +/** + * `GET /pet-work` 的读数(2026-10-23 实测形状): + * + * ``` + * { enabled, businessDate, + * config: { version, dailyMaxPoints, tiers: [{ tierId, score }] }, + * status: { earnedPointsToday, rewardedTierIds, dailyRewardCapped } } + * ``` + * + * ⚠️ 配置版本在快照里叫 `config.version`,领奖请求体里叫 `configVersion`: + * 两处拼写不同是服务端的形状,别顺手统一。 + */ +export interface LoomyPetWork { + enabled: boolean + businessDate?: string + configVersion?: string + dailyMax?: number + tiers: LoomyPetWorkTier[] + earnedToday?: number + rewardedTiers: string[] + capped: boolean + /** 还没领到的最低档;undefined 表示今日没有可申请的档位。 */ + nextTier?: string +} + +/** 档位表:缺 `tierId` 或 `score` 的条目直接丢掉,不猜。 */ +function readTiers(value: unknown): LoomyPetWorkTier[] { + const list = asArray(value) + if (list === undefined) return [] + const tiers: LoomyPetWorkTier[] = [] + for (const item of list) { + const record = asObject(item) + const id = str(record?.tierId) + const score = num(record?.score) + if (id === undefined || score === undefined) continue + tiers.push({ id, score }) + } + return tiers +} + +/** + * 该申请哪一档:已发档位的下一个。 + * + * 本包拿不到该账号的轮次统计,所以只能按「已发档位」推进:一次都没领就申请最低档 + * (够不够由服务端判),全领完则不申请。拿不到档位表时不猜更高档 —— `tier_1` 已发 + * 就视为完成。 + */ +function nextTierOf(tiers: readonly LoomyPetWorkTier[], rewarded: readonly string[]): string | undefined { + const done = new Set(rewarded) + if (tiers.length === 0) return done.has(LOOMY_PETWORK_FIRST_TIER) ? undefined : LOOMY_PETWORK_FIRST_TIER + for (const tier of tiers) { + if (!done.has(tier.id)) return tier.id + } + return undefined +} + +function readPetWork(data: Record | undefined): LoomyPetWork { + const config = asObject(data?.config) ?? {} + const status = asObject(data?.status) ?? {} + const tiers = readTiers(config.tiers) + const rewardedTiers = stringList(status.rewardedTierIds) + const businessDate = str(data?.businessDate) + // 历史形状把配置版本平铺在 data 上,两条都读;都没有才让调用方用兜底常量。 + const configVersion = str(config.version) ?? str(data?.configVersion) + const dailyMax = num(config.dailyMaxPoints) + const earnedToday = num(status.earnedPointsToday) + const nextTier = nextTierOf(tiers, rewardedTiers) + return { + enabled: bool(data?.enabled) === true, + tiers, + rewardedTiers, + capped: bool(status.dailyRewardCapped) === true, + ...(businessDate === undefined ? {} : { businessDate }), + ...(configVersion === undefined ? {} : { configVersion }), + ...(dailyMax === undefined ? {} : { dailyMax }), + ...(earnedToday === undefined ? {} : { earnedToday }), + ...(nextTier === undefined ? {} : { nextTier }), + } +} + +/** 宠物打工的当日状态,只读。活动关闭时 `enabled` 为 false,其余字段可能缺。 */ +export async function fetchPetWork(fetcher: Fetcher, access: string): Promise> { + const result = await productRead>(fetcher, access, LOOMY_PATHS.petWork) + if (!result.ok) return result + return { ok: true, data: readPetWork(result.data) } +} + +/** 领奖回执(2026-10-23 实测「已领」形态:`duplicate: true` + `granted: false`)。 */ +export interface LoomyPetWorkGrant { + businessDate?: string + granted: boolean + duplicate: boolean + points?: number + grantedTiers: string[] + earnedToday?: number + capped: boolean +} + +function readPetWorkGrant(data: Record | undefined): LoomyPetWorkGrant { + const status = asObject(data?.status) ?? {} + const businessDate = str(data?.businessDate) + const points = num(data?.grantedPoints) + const earnedToday = num(status.earnedPointsToday) + return { + granted: bool(data?.granted) === true, + duplicate: bool(data?.duplicate) === true, + grantedTiers: stringList(data?.grantedTierIds), + capped: bool(status.dailyRewardCapped) === true, + ...(businessDate === undefined ? {} : { businessDate }), + ...(points === undefined ? {} : { points }), + ...(earnedToday === undefined ? {} : { earnedToday }), + } +} + +/** + * 申请一档奖励。请求体只有服务端认的三个字段(`businessDate` / `configVersion` / + * `reachedTierId`);`configVersion` 必须取自快照的 `config.version`,编一个版本号 + * 会被服务端判过期。 + */ +export async function claimPetWorkReward( + fetcher: Fetcher, + access: string, + input: { businessDate: string; configVersion: string; tierId: string }, +): Promise> { + const result = await productRead>(fetcher, access, LOOMY_PATHS.petWorkRewards, { + method: "POST", + body: JSON.stringify({ + businessDate: input.businessDate, + configVersion: input.configVersion, + reachedTierId: input.tierId, + }), + }) + if (!result.ok) return result + return { ok: true, data: readPetWorkGrant(result.data) } +} + // ---- WeChat's own pages ------------------------------------------------------ export function wechatAuthUrl(state: string): string { diff --git a/src/vendors/loomy/index.ts b/src/vendors/loomy/index.ts index 0de58b4..4d514cd 100644 --- a/src/vendors/loomy/index.ts +++ b/src/vendors/loomy/index.ts @@ -14,7 +14,7 @@ import { gatedFetch } from "../../core/gate.ts" import { vendorLogger } from "../../core/log.ts" import { markFallback, toConfigModels, toModelRecord } from "../../core/models.ts" import { defineVendor, modelContextOf, type VendorContext } from "../../core/plugin.ts" -import type { ModelRecord, UsageReport } from "../../core/types.ts" +import type { ModelRecord, UsageReport, UsageWindow } from "../../core/types.ts" import { chatHeaders, claimDailyQuota, @@ -26,6 +26,7 @@ import { import { lockedRefusal, rememberDailyBalance } from "./balance.ts" import { loginMethods } from "./login.ts" import { fallbackModels, modelsFrom } from "./models.ts" +import { claimPetWork, petWorkStateFor, petWorkWindow } from "./petwork.ts" import { LOOMY_API_BASE, LOOMY_ICON, LOOMY_ID, LOOMY_NAME, LOOMY_SIGNED_OUT_CODE } from "./product.ts" import { accountLabel, rememberedQuota, rememberQuota, requireSession, sessionOf } from "./session.ts" import { claimTasksEnabled, forgetTaskReading, taskStateFor, tasksWindow } from "./tasks.ts" @@ -116,10 +117,16 @@ export const LoomyAuthPlugin = defineVendor(({ client, options }) => { // would have been (`tasks.ts`). `options.claimTasks: false` keeps this // read-only. A read that fails just leaves the line out. const state = await taskStateFor(fetch, auth, access, Date.now(), claimTasksEnabled(options)) + const extraWindows: UsageWindow[] = [] const tasks = tasksWindow(state?.data) + if (tasks !== undefined) extraWindows.push(tasks) + // 宠物打工只读一行进度;领奖挂在 `checkin`(为什么不在这一钩子里领,见 + // `petwork.ts` 的文件头:未达档位是常态,读一次就写一次只是白烧请求)。 + const petwork = petWorkWindow(await petWorkStateFor(fetch, auth, access)) + if (petwork !== undefined) extraWindows.push(petwork) return usageReport(today, { ...(accountLabel(auth) === undefined ? {} : { user: accountLabel(auth) as string }), - ...(tasks === undefined ? {} : { extraWindows: [tasks] }), + ...(extraWindows.length === 0 ? {} : { extraWindows }), }) }, @@ -162,7 +169,11 @@ export const LoomyAuthPlugin = defineVendor(({ client, options }) => { // reads the service again instead of showing "N/8 可领" for the rest of // the TTL right after the tasks were claimed (`tasks.ts`). forgetTaskReading(auth) - return checkinResult(grant.data, tasks.ok ? tasks.data : undefined) + // 宠物打工:档位由服务端按官方客户端的有效对话轮次算,这里只把已达档位的分 + // 收下来。快照读失败只有 `detail`(进日志),不往签到文案里写「读不到」。 + const petwork = await claimPetWork(fetch, access, auth) + if (petwork.detail !== undefined) await log.warn(petwork.detail) + return checkinResult(grant.data, tasks.ok ? tasks.data : undefined, petwork) }, } }) diff --git a/src/vendors/loomy/petwork.ts b/src/vendors/loomy/petwork.ts new file mode 100644 index 0000000..a1b586a --- /dev/null +++ b/src/vendors/loomy/petwork.ts @@ -0,0 +1,298 @@ +/** + * 宠物每日打工(pet-work):卡片上的那一行,以及签到时的领奖。 + * + * ## 为什么领奖只挂在 `auth.checkin` + * + * 档位由服务端按**该账号当日在官方客户端的有效对话轮次**算(`sessionWeight × + * sessionIndex + depthWeight × depthIndex`,权重与基线都由快照下发)。本包是网关: + * 它转发用户的对话请求,但不产生轮次归属,也拿不到官方客户端里的轮次统计 —— + * 也就是说,这里每次领奖对「够不够档」毫无影响,只在用户当天已经攒够轮次时把分 + * 收下来。收益是用户的,动作就该落在用户按得下去的地方:`auth.checkin` + * (卡片上的 `Check in now`、`magpie accounts checkin`、每日自动签到)。 + * + * 卡片读到进度就顺手领(新手任务那套)在这里**不**合适:未达档位是常态,每次额度 + * 刷新都发一个注定被拒的 POST 只是白烧上游请求。 + * + * 一次签到会把今天未领的档位逐档申请(最多档位数那么多),某档没达标就停手 —— + * 用户当天在官方客户端聊够了却一直没按签到时,一档都不会漏掉。 + * + * ## 为什么这一行会自己消失 + * + * 封顶(`dailyRewardCapped`)说明今天的 1000 分已经收完,这一行没有可说的了 —— + * 与新手任务 8/8 时不显示同一取舍。活动关闭时也不显示。 + * + * 读数按账号缓存 `LOOMY_PETWORK_TTL_MS`:`usage` 每轮额度刷新都会跑到这里,而进度 + * 只在官方客户端产生新轮次、或本包领奖之后变化。 + */ + +import { SignInExpiredError } from "../../core/errors.ts" +import type { Fetcher } from "../../core/http.ts" +import { str } from "../../core/json.ts" +import type { StoredAuth, UsageWindow } from "../../core/types.ts" +import { claimPetWorkReward, fetchPetWork, type LoomyPetWork } from "./api.ts" +import { + LOOMY_ID, + LOOMY_PETWORK_CONFIG_VERSION, + LOOMY_PETWORK_TTL_MS, + LOOMY_SIGNED_OUT_CODE, +} from "./product.ts" + +interface Reading { + at: number + data: LoomyPetWork +} + +const readings = new Map() + +/** + * 读数的键:厂商自己的 user id 优先(跨重新登录稳定),退回 session。 + * 宿主的账号存储里没有本包要的位置,所以缓存必须能从账号本身重建。 + */ +function keyOf(auth: StoredAuth | undefined): string | undefined { + return str(auth?.userid) ?? str(auth?.access) ?? str(auth?.key) +} + +/** 忘掉所有读数;测试用,宿主不需要。 */ +export function forgetPetWorkReadings(): void { + readings.clear() +} + +/** + * 忘掉一个账号的读数,下一次 `usage` 重新读服务端。 + * + * 领奖之后必须调用:缓存里那份是领奖前的状态,不丢就会在卡片上继续显示旧进度。 + */ +export function forgetPetWorkReading(auth: StoredAuth | undefined): void { + const key = keyOf(auth) + if (key !== undefined) readings.delete(key) +} + +/** + * 该账号今天的宠物打工状态,缓存优先。 + * + * 读失败返回 `undefined` 而不是抛错:卡片少一行不值得让整个额度读取失败。 + */ +export async function petWorkStateFor( + fetcher: Fetcher, + auth: StoredAuth | undefined, + access: string, + now = Date.now(), +): Promise { + const key = keyOf(auth) + if (key !== undefined) { + const cached = readings.get(key) + if (cached !== undefined && now - cached.at <= LOOMY_PETWORK_TTL_MS) return cached.data + } + const result = await fetchPetWork(fetcher, access) + if (!result.ok) return undefined + if (key !== undefined) readings.set(key, { at: now, data: result.data }) + return result.data +} + +/** + * 卡片上的那一行,或什么都不显示。 + * + * `aside: true` 且不带 `limit` / `amount`:这一行只解释今天有没有分可领,不该被 + * 画成「池子被花掉多少」的进度条,也不该参与选号(封顶与否不是路由依据)。 + */ +export function petWorkWindow(state: LoomyPetWork | undefined): UsageWindow | undefined { + if (state === undefined || !state.enabled || state.capped) return undefined + const earned = state.earnedToday ?? 0 + const parts = [state.dailyMax === undefined ? `今日 ${earned} 分` : `今日 ${earned}/${state.dailyMax}`] + if (earned > 0 && state.tiers.length > 0) + parts.push(`已领 ${state.rewardedTiers.length}/${state.tiers.length} 档`) + if (earned === 0) parts.push("未达档位") + return { + name: "宠物打工", + used: 0, + aside: true, + unit: "分", + display: `宠物打工 ${parts.join(" · ")}`, + } +} + +/** 一次领奖尝试的结果。`message` 是给用户看的,`detail` 只进日志。 */ +export interface PetWorkClaim { + outcome: "claimed" | "done" | "inactive" | "notReached" | "failed" + /** 本次实际拿到的分数;`done` / `notReached` 为 0。 */ + points: number + /** 面向用户的一句话;读快照失败时为空(不把「读不到」说成结论)。 */ + message?: string + /** 只进日志的细节,不拼进签到文案。 */ + detail?: string +} + +function doneMessage(state: LoomyPetWork): string { + return state.earnedToday === undefined + ? "今日宠物打工积分已领取" + : `今日宠物打工积分已领取(${state.earnedToday} 分)` +} + +function notReachedMessage(earned: number | undefined): string { + const head = "宠物打工未达档位(档位按官方客户端的有效对话轮次计算)" + return earned !== undefined && earned > 0 ? `${head},今日已获 ${earned} 分` : `${head},本次没有可领积分` +} + +/** + * 今日还没领的档位,按档位表顺序。 + * + * 拿不到档位表时只有快照给的那一档(兜底 `tier_1`)—— 不猜更高档。封顶由调用方先判。 + */ +function pendingTiers(state: LoomyPetWork): string[] { + const done = new Set(state.rewardedTiers) + if (state.tiers.length === 0) return state.nextTier === undefined ? [] : [state.nextTier] + return state.tiers.map((tier) => tier.id).filter((id) => !done.has(id)) +} + +/** + * 领今天的宠物打工奖励:读快照 → 按档位顺序把未领的档逐档申请。 + * + * 一次签到可能攒下不止一档(用户当天在官方客户端聊够了却一直没领),所以逐档申请; + * 某档没达标就停手 —— 档位按分数单调,这一档没到,更高的更不可能到。回执里带回的 + * `grantedTierIds` 会并进「已领」,因此服务端若一次发多档也不会重复申请。 + * + * 状态映射(每一步都有实测或既有实现的依据,见 `docs/vendors.md`): + * + * ``` + * 快照读不到 -> failed + detail(不拼进签到文案) + * enabled=false -> inactive + * 封顶 / 没有未领档位 -> done(一个领奖请求都不发) + * granted=true(或带回档位) -> claimed,分数按 grantedPoints,缺失时用今日累计差分 + * duplicate -> done + * granted=false 且无档位 -> 未达档位:停手;一档都没领到就报 notReached + * 100002 -> SignInExpiredError(宿主标记账号) + * 其它业务码 -> 停手;一档都没领到就报 failed + 厂商原话 + * ``` + */ +export async function claimPetWork( + fetcher: Fetcher, + access: string, + auth: StoredAuth | undefined, +): Promise { + const state = await fetchPetWork(fetcher, access) + if (!state.ok) { + if (state.code === LOOMY_SIGNED_OUT_CODE) { + throw new SignInExpiredError(LOOMY_ID, `宠物打工被拒绝:${state.message}`) + } + return { outcome: "failed", points: 0, detail: `读 pet-work 快照失败:${state.message}` } + } + const pet = state.data + if (!pet.enabled) return { outcome: "inactive", points: 0, message: "宠物打工活动当前未开启" } + const businessDate = pet.businessDate + if (businessDate === undefined) { + // 没有业务日就不发领奖请求:服务端会以必填校验拒绝,白发一次只消耗上游风控。 + return { outcome: "failed", points: 0, detail: "pet-work 快照里没有 businessDate,跳过领奖" } + } + const pending = pendingTiers(pet) + if (pet.capped || pending.length === 0) { + return { outcome: "done", points: 0, message: doneMessage(pet) } + } + const attempt = await claimTiers(fetcher, access, pet, businessDate, pending) + // 回执已经改变了当日状态,卡片那份读数作废。 + forgetPetWorkReading(auth) + return petWorkOutcome(attempt) +} + +/** 一轮领奖跑下来的账:发了多少分、领到几档、为什么停下。 */ +interface PetWorkAttempt { + points: number + granted: number + /** 至少有一档没回填 `grantedPoints`,分数是按今日累计差分算的。 */ + usedDiff: boolean + duplicate: boolean + earned?: number + /** 让循环停下的厂商原话(业务码失败时)。 */ + failure?: string +} + +/** + * 按档位顺序逐档申请,直到某档未达标、已领过、被拒或封顶。 + * + * 单独成函数是为了让 `claimPetWork` 的判定链读得下来(每个 `if` 都是对外状态)。 + */ +async function claimTiers( + fetcher: Fetcher, + access: string, + pet: LoomyPetWork, + businessDate: string, + pending: readonly string[], +): Promise { + const attempt: PetWorkAttempt = { + points: 0, + granted: 0, + usedDiff: false, + duplicate: false, + ...(pet.earnedToday === undefined ? {} : { earned: pet.earnedToday }), + } + const done = new Set(pet.rewardedTiers) + for (const tierId of pending) { + if (done.has(tierId)) continue + const before = attempt.earned + const result = await claimPetWorkReward(fetcher, access, { + businessDate, + configVersion: pet.configVersion ?? LOOMY_PETWORK_CONFIG_VERSION, + tierId, + }) + if (!result.ok) { + if (result.code === LOOMY_SIGNED_OUT_CODE) { + throw new SignInExpiredError(LOOMY_ID, `宠物打工被拒绝:${result.message}`) + } + attempt.failure = result.message + break + } + const grant = result.data + if (grant.earnedToday !== undefined) attempt.earned = grant.earnedToday + for (const id of grant.grantedTiers) done.add(id) + if (!grant.granted && grant.grantedTiers.length === 0) { + // granted=false 且没有档位:这一档没达标,更高的档更不可能达标;duplicate 则是今天已领过。 + attempt.duplicate = grant.duplicate + break + } + attempt.points += grantPoints(grant.points, grant.earnedToday, before) + if (grant.points === undefined || grant.points <= 0) attempt.usedDiff = true + attempt.granted += 1 + if (grant.capped) break + } + return attempt +} + +function petWorkOutcome(attempt: PetWorkAttempt): PetWorkClaim { + if (attempt.granted > 0) { + return { + outcome: "claimed", + points: attempt.points, + message: claimedMessage(attempt), + ...(attempt.failure === undefined ? {} : { detail: `部分档位未领:${attempt.failure}` }), + } + } + if (attempt.failure !== undefined) { + return { outcome: "failed", points: 0, message: `宠物打工领取失败:${attempt.failure}` } + } + if (attempt.duplicate) { + return { + outcome: "done", + points: 0, + message: + attempt.earned === undefined + ? "今日宠物打工积分已领取" + : `今日宠物打工积分已领取(${attempt.earned} 分)`, + } + } + return { outcome: "notReached", points: 0, message: notReachedMessage(attempt.earned) } +} + +function claimedMessage(attempt: PetWorkAttempt): string { + if (attempt.points <= 0) return "宠物打工奖励已发放(上游未回填金额)" + return `宠物打工奖励 +${attempt.points} 分${attempt.usedDiff ? "(按今日累计差分)" : ""}` +} + +/** 本次发放额:回执明说的优先,否则用今日累计的差分。 */ +function grantPoints( + stated: number | undefined, + after: number | undefined, + before: number | undefined, +): number { + if (stated !== undefined && stated > 0) return stated + if (after === undefined || before === undefined) return 0 + return Math.max(0, after - before) +} diff --git a/src/vendors/loomy/product.ts b/src/vendors/loomy/product.ts index 59ac99c..a651ea1 100644 --- a/src/vendors/loomy/product.ts +++ b/src/vendors/loomy/product.ts @@ -320,6 +320,8 @@ export const LOOMY_PATHS = Object.freeze({ firstLogin: "/points/first-login", onboardingTasks: "/onboarding/tasks", onboardingComplete: "/onboarding/tasks/complete", + petWork: "/pet-work", + petWorkRewards: "/pet-work/rewards", }) /** @@ -367,3 +369,26 @@ export const LOOMY_TASK_TITLES: Readonly> = Object.freeze create_soul: "创建你的第一个搭子", share_soul: "把搭子分享给朋友", }) + +/** + * 宠物每日打工(pet-work):Loomy 唯一「按天算、要手动领」的积分,每天 0~1000 分。 + * + * 档位由服务端按**该账号当日在官方客户端的有效对话轮次**计算 + * (`sessionWeight × sessionIndex + depthWeight × depthIndex`,见快照 `config`), + * 本包只提交「业务日 + 配置版本 + 已达档位」,是否达标由服务端判。 + * + * ⚠️ 两处拼写不同,别顺手统一:快照里配置版本是 `config.version`,而领奖请求体 + * 的字段**是** `configVersion`(2026-10-23 实测,见 `docs/vendors.md`)。 + */ +export const LOOMY_PETWORK_CONFIG_VERSION = "pet-work-v1" + +/** 拿不到快照档位表时的兜底档位;服务端只认它自己的档位 id。 */ +export const LOOMY_PETWORK_FIRST_TIER = "tier_1" + +/** + * 卡片读数的缓存时长。 + * + * 进度只在官方客户端产生新轮次、或本包领奖之后变化,而 `usage` 每轮额度刷新都会 + * 跑,10 分钟一次足够(与新手任务同一口径)。 + */ +export const LOOMY_PETWORK_TTL_MS = 10 * 60 * 1000 diff --git a/src/vendors/loomy/usage.ts b/src/vendors/loomy/usage.ts index 48c7f52..fcb4186 100644 --- a/src/vendors/loomy/usage.ts +++ b/src/vendors/loomy/usage.ts @@ -22,6 +22,7 @@ import type { CheckinResult, UsageReport, UsageWindow } from "../../core/types.ts" import type { LoomyDailyGrant, LoomyOnboardingClaim, LoomyPoints } from "./api.ts" +import type { PetWorkClaim } from "./petwork.ts" import { LOOMY_TASK_TITLES } from "./product.ts" /** The Beijing day's reset, as epoch milliseconds. */ @@ -173,18 +174,39 @@ export function usageReport( * like a daily quota. They are named in the message instead, and the outcome * only stays `claimed` when the day's own quota was handed out — finishing a * task is not the day's check-in. + * + * `petwork` is the pet-work reward, kept out of `credit` for the same reason: + * it is a separate pool with its own cap (1 000 a day), and reporting it as + * the day's quota would misstate both. */ -export function checkinResult(grant: LoomyDailyGrant, onboarding?: LoomyOnboardingClaim): CheckinResult { +export function checkinResult( + grant: LoomyDailyGrant, + onboarding?: LoomyOnboardingClaim, + petwork?: PetWorkClaim, +): CheckinResult { const quota = grant.dailyQuota const left = grant.daily const detail = quota === undefined ? "" : `(今日 ${left ?? 0}/${quota})` const tasks = onboardingMessage(onboarding) + const pet = petWorkMessage(petwork) if (grant.alreadyProcessed) { - return { outcome: "done", credit: 0, message: `今日额度已初始化${detail}${tasks}` } + return { outcome: "done", credit: 0, message: `今日额度已初始化${detail}${tasks}${pet}` } } const consumed = grant.dailyConsumed const credit = quota === undefined ? 0 : consumed === undefined ? quota : Math.max(0, quota - consumed) - return { outcome: "claimed", credit, message: `今日额度已发放${detail}${tasks}` } + return { outcome: "claimed", credit, message: `今日额度已发放${detail}${tasks}${pet}` } +} + +/** + * What to say about the pet-work claim, or nothing when it has nothing to say. + * + * A failed *read* carries no message on purpose (`petwork.ts`): "could not read + * the snapshot" is not a fact about the user's reward, and the check-in line is + * the wrong place to report a probe that failed. + */ +function petWorkMessage(claim: PetWorkClaim | undefined): string { + if (claim === undefined || claim.message === undefined) return "" + return `;${claim.message}` } /** diff --git a/tests/loomy.petwork.test.ts b/tests/loomy.petwork.test.ts new file mode 100644 index 0000000..ed01c61 --- /dev/null +++ b/tests/loomy.petwork.test.ts @@ -0,0 +1,406 @@ +/** + * 保护宠物每日打工(pet-work)的读数与领奖:快照形状(配置版本在 + * `config.version`、领奖请求体里叫 `configVersion`)、按已发档位推进、 + * 封顶不发请求、未达档位不冒充领到、100002 抛失效、读不到时保持沉默, + * 以及卡片那一行的显示与缓存。 + * + * 全部离线:快照与回执都按 2026-10-23 的实测形状造(见 docs/vendors.md)。 + */ + +import { describe, expect, test } from "bun:test" +import { SignInExpiredError } from "../src/core/errors.ts" +import { fetchPetWork } from "../src/vendors/loomy/api.ts" +import { + claimPetWork, + forgetPetWorkReadings, + petWorkStateFor, + petWorkWindow, +} from "../src/vendors/loomy/petwork.ts" +import { cannedFetch, type FetchInit, type FetchResponse, jsonOk, rejectionError } from "./helpers.ts" + +const AUTH = { type: "oauth", access: "sess-1", userid: "u-1" } + +const TIERS = [ + { tierId: "tier_1", score: 60 }, + { tierId: "tier_2", score: 100 }, + { tierId: "tier_3", score: 150 }, +] + +interface SnapshotOptions { + enabled?: boolean + rewarded?: string[] + earned?: number + capped?: boolean + tiers?: unknown +} + +/** `GET /pet-work` 的实测形状。 */ +function snapshot(options: SnapshotOptions = {}) { + return jsonOk({ + code: "000000", + desc: "成功", + data: { + enabled: options.enabled ?? true, + businessDate: "2026-10-23", + config: { + version: "pet-work-v1", + dailyMaxPoints: 1000, + tiers: options.tiers === undefined ? TIERS : options.tiers, + }, + status: { + earnedPointsToday: options.earned ?? 0, + rewardedTierIds: options.rewarded ?? [], + dailyRewardCapped: options.capped ?? false, + }, + }, + }) +} + +/** `POST /pet-work/rewards` 的回执:granted / duplicate / 未达档位三种形态。 */ +function grant(options: { + granted?: boolean + duplicate?: boolean + points?: number + tiers?: string[] + earned?: number + capped?: boolean +}) { + return jsonOk({ + code: "000000", + desc: "成功", + data: { + businessDate: "2026-10-23", + grantBatchId: options.granted === true ? "pwg_1" : "", + granted: options.granted ?? false, + duplicate: options.duplicate ?? false, + grantedPoints: options.points ?? 0, + grantedTierIds: options.tiers ?? [], + status: { + earnedPointsToday: options.earned ?? 0, + rewardedTierIds: options.tiers ?? [], + dailyRewardCapped: options.capped ?? false, + }, + }, + }) +} + +/** + * 按请求体里的 `reachedTierId` 作答:表里没有的档位一律回「未达档位」, + * 并带上当时已经拿到的累计分(回执里 `status.earnedPointsToday` 是权威值)。 + */ +function rewardsByTier(answers: Record, notReachedEarned: number) { + return (_url: string, init: FetchInit | undefined): FetchResponse => { + const body = JSON.parse(String(init?.body ?? "{}")) as { reachedTierId?: string } + const answer = answers[body.reachedTierId ?? ""] + return (answer ?? grant({ granted: false, earned: notReachedEarned })).clone() as FetchResponse + } +} + +describe("pet-work 快照", () => { + test("按实测形状读:配置版本在 config.version,档位与当日累计都拿到", async () => { + const fetcher = cannedFetch([ + { match: "/pet-work", respond: snapshot({ earned: 300, rewarded: ["tier_1"] }) }, + ]) + const result = await fetchPetWork(fetcher, "sess-1") + if (!result.ok) throw new Error("快照必须读成功") + expect(result.data.enabled).toBe(true) + expect(result.data.businessDate).toBe("2026-10-23") + // ⚠️ 快照里是 config.version;领奖请求体里是 configVersion。 + expect(result.data.configVersion).toBe("pet-work-v1") + expect(result.data.dailyMax).toBe(1000) + expect(result.data.tiers).toStrictEqual([ + { id: "tier_1", score: 60 }, + { id: "tier_2", score: 100 }, + { id: "tier_3", score: 150 }, + ]) + expect(result.data.earnedToday).toBe(300) + expect(result.data.nextTier).toBe("tier_2") + }) + + test("已发档位决定下一次申请哪一档;全领完就没有可申请的", async () => { + const partial = await fetchPetWork( + cannedFetch([{ match: "/pet-work", respond: snapshot({ rewarded: ["tier_1", "tier_2"] }) }]), + "sess-1", + ) + expect(partial.ok && partial.data.nextTier).toBe("tier_3") + + const all = await fetchPetWork( + cannedFetch([{ match: "/pet-work", respond: snapshot({ rewarded: ["tier_1", "tier_2", "tier_3"] }) }]), + "sess-1", + ) + expect(all.ok && all.data.nextTier).toBeUndefined() + }) + + test("拿不到档位表时不猜更高档:tier_1 已发就视为完成", async () => { + const fresh = await fetchPetWork( + cannedFetch([{ match: "/pet-work", respond: snapshot({ tiers: [] }) }]), + "sess-1", + ) + expect(fresh.ok && fresh.data.nextTier).toBe("tier_1") + + const done = await fetchPetWork( + cannedFetch([{ match: "/pet-work", respond: snapshot({ tiers: [], rewarded: ["tier_1"] }) }]), + "sess-1", + ) + expect(done.ok && done.data.nextTier).toBeUndefined() + }) + + test("配置版本平铺在 data 上时也读得到(历史形状)", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work", + respond: jsonOk({ + code: "000000", + data: { enabled: true, businessDate: "2026-10-23", configVersion: "pet-work-v9", status: {} }, + }), + }, + ]) + const result = await fetchPetWork(fetcher, "sess-1") + expect(result.ok && result.data.configVersion).toBe("pet-work-v9") + // 没有档位表、也没发过档:兜底申请最低档。 + expect(result.ok && result.data.nextTier).toBe("tier_1") + }) +}) + +describe("pet-work 领奖", () => { + test("请求体只有三个字段,configVersion 取自快照而不是常量", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: rewardsByTier( + { tier_1: grant({ granted: true, points: 200, tiers: ["tier_1"], earned: 200 }) }, + 200, + ), + }, + { match: "/pet-work", respond: snapshot() }, + ]) + await claimPetWork(fetcher, "sess-1", AUTH) + const calls = fetcher.callsMatching("/pet-work/rewards") + // tier_1 领到,tier_2 未达就停手:不会把三档都撞一遍。 + expect(calls).toHaveLength(2) + expect(JSON.parse(String(calls[0]?.init?.body))).toStrictEqual({ + businessDate: "2026-10-23", + configVersion: "pet-work-v1", + reachedTierId: "tier_1", + }) + expect(calls[0]?.init?.method).toBe("POST") + expect(JSON.parse(String(calls[1]?.init?.body)).reachedTierId).toBe("tier_2") + }) + + test("granted=true 时把发放额报出来", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: rewardsByTier( + { tier_1: grant({ granted: true, points: 200, tiers: ["tier_1"], earned: 200 }) }, + 200, + ), + }, + { match: "/pet-work", respond: snapshot() }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("claimed") + expect(result.points).toBe(200) + expect(result.message).toBe("宠物打工奖励 +200 分") + }) + + test("一天攒下多档时逐档领完,某档未达就停手", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: rewardsByTier( + { + tier_1: grant({ granted: true, points: 200, tiers: ["tier_1"], earned: 200 }), + tier_2: grant({ granted: true, points: 300, tiers: ["tier_2"], earned: 500 }), + }, + 500, + ), + }, + { match: "/pet-work", respond: snapshot() }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("claimed") + expect(result.points).toBe(500) + expect(result.message).toBe("宠物打工奖励 +500 分") + // tier_3 没达标:请求发了,但结果不再往上报。 + expect(fetcher.callsMatching("/pet-work/rewards")).toHaveLength(3) + }) + + test("duplicate 是「今天已领过」,不是又领了一次", async () => { + const fetcher = cannedFetch([ + { match: "/pet-work/rewards", respond: grant({ duplicate: true, earned: 1000, capped: true }) }, + { match: "/pet-work", respond: snapshot({ earned: 500, rewarded: ["tier_1"] }) }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("done") + expect(result.points).toBe(0) + expect(result.message).toBe("今日宠物打工积分已领取(1000 分)") + }) + + test("封顶时一个领奖请求都不发", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work", + respond: snapshot({ earned: 1000, rewarded: ["tier_1", "tier_2", "tier_3"], capped: true }), + }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("done") + expect(result.message).toContain("已领取") + expect(fetcher.callsMatching("/pet-work/rewards")).toHaveLength(0) + }) + + test("未达档位如实上报,不冒充领到", async () => { + const fetcher = cannedFetch([ + { match: "/pet-work/rewards", respond: grant({ granted: false }) }, + { match: "/pet-work", respond: snapshot() }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("notReached") + expect(result.points).toBe(0) + expect(result.message).toContain("未达档位") + }) + + test("有档位但没回填金额时按今日累计差分,不编数字", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: rewardsByTier({ tier_2: grant({ granted: false, tiers: ["tier_2"], earned: 300 }) }, 300), + }, + { match: "/pet-work", respond: snapshot({ earned: 100, rewarded: ["tier_1"] }) }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("claimed") + expect(result.points).toBe(200) + expect(result.message).toContain("差分") + }) + + test("业务码失败如实返回 failed,带厂商原话", async () => { + const fetcher = cannedFetch([ + { match: "/pet-work/rewards", respond: jsonOk({ code: "520001", desc: "额度服务抖动" }) }, + { match: "/pet-work", respond: snapshot() }, + ]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("failed") + expect(result.message).toBe("宠物打工领取失败:额度服务抖动") + }) + + test("100002 抛 SignInExpiredError,让宿主标记账号", async () => { + const fetcher = cannedFetch([ + { match: "/pet-work/rewards", respond: jsonOk({ code: "100002", desc: "缺少 token" }) }, + { match: "/pet-work", respond: snapshot() }, + ]) + const error = await rejectionError(() => claimPetWork(fetcher, "sess-1", AUTH)) + expect(error).toBeInstanceOf(SignInExpiredError) + expect(error.message).toBe("loomy: 宠物打工被拒绝:缺少 token") + }) + + test("快照读不到时保持沉默:只有日志细节,没有给用户的结论", async () => { + // 没有路由 ⇒ cannedFetch 抛错 ⇒ productRead 归成 NETWORK 失败。 + const fetcher = cannedFetch([]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("failed") + expect(result.message).toBeUndefined() + expect(result.detail).toContain("读 pet-work 快照失败") + }) + + test("活动关闭时不发领奖请求", async () => { + const fetcher = cannedFetch([{ match: "/pet-work", respond: snapshot({ enabled: false }) }]) + const result = await claimPetWork(fetcher, "sess-1", AUTH) + expect(result.outcome).toBe("inactive") + expect(result.message).toBe("宠物打工活动当前未开启") + expect(fetcher.callsMatching("/pet-work/rewards")).toHaveLength(0) + }) +}) + +describe("宠物打工的卡片行", () => { + test("一分没有时说清原因,且不参与选号", () => { + const window = petWorkWindow({ + enabled: true, + businessDate: "2026-10-23", + configVersion: "pet-work-v1", + dailyMax: 1000, + tiers: [{ id: "tier_1", score: 60 }], + earnedToday: 0, + rewardedTiers: [], + capped: false, + nextTier: "tier_1", + }) + expect(window?.aside).toBe(true) + expect(window?.used).toBe(0) + expect(window?.display).toBe("宠物打工 今日 0/1000 · 未达档位") + // 不带 limit/amount:卡片不该把它画成「池子被花掉多少」。 + expect(window?.limit).toBeUndefined() + expect(window?.amount).toBeUndefined() + }) + + test("已领部分档位时显示进度", () => { + const window = petWorkWindow({ + enabled: true, + dailyMax: 1000, + tiers: [ + { id: "tier_1", score: 60 }, + { id: "tier_2", score: 100 }, + { id: "tier_3", score: 150 }, + ], + earnedToday: 300, + rewardedTiers: ["tier_1"], + capped: false, + nextTier: "tier_2", + }) + expect(window?.display).toBe("宠物打工 今日 300/1000 · 已领 1/3 档") + }) + + test("封顶与活动关闭都不显示", () => { + const base = { + enabled: true, + dailyMax: 1000, + tiers: [{ id: "tier_1", score: 60 }], + earnedToday: 1000, + rewardedTiers: ["tier_1"], + } + expect(petWorkWindow({ ...base, capped: true })).toBeUndefined() + expect(petWorkWindow({ ...base, capped: false, enabled: false })).toBeUndefined() + expect(petWorkWindow(undefined)).toBeUndefined() + }) + + test("读数按账号缓存:同一次刷新里不重复打上游", async () => { + forgetPetWorkReadings() + const fetcher = cannedFetch([{ match: "/pet-work", respond: snapshot() }]) + const now = 1_000_000 + const first = await petWorkStateFor(fetcher, AUTH, "sess-1", now) + const second = await petWorkStateFor(fetcher, AUTH, "sess-1", now + 60_000) + expect(first?.enabled).toBe(true) + expect(second?.enabled).toBe(true) + expect(fetcher.calls.filter((call) => call.url.endsWith("/pet-work"))).toHaveLength(1) + }) + + test("领奖之后缓存作废,下一次读拿到新进度", async () => { + forgetPetWorkReadings() + let claimed = false + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: () => { + claimed = true + return grant({ granted: true, points: 200, tiers: ["tier_1"], earned: 200 }) + }, + }, + { + match: "/pet-work", + respond: () => snapshot({ earned: claimed ? 200 : 0, rewarded: claimed ? ["tier_1"] : [] }), + }, + ]) + const before = await petWorkStateFor(fetcher, AUTH, "sess-1", 1_000_000) + expect(before?.earnedToday).toBe(0) + + await claimPetWork(fetcher, "sess-1", AUTH) + + const after = await petWorkStateFor(fetcher, AUTH, "sess-1", 1_000_100) + expect(after?.earnedToday).toBe(200) + // 三次快照:卡片第一次读、领奖自己要先读一次、卡片第二次读(缓存已作废)。 + // 只数快照那一路:`/pet-work/rewards` 也含 `/pet-work` 这个子串。 + const snapshots = fetcher.calls.filter((call) => call.url.endsWith("/pet-work")) + expect(snapshots).toHaveLength(3) + }) +}) diff --git a/tests/loomy.plugin.test.ts b/tests/loomy.plugin.test.ts index 183ae2f..341eac7 100644 --- a/tests/loomy.plugin.test.ts +++ b/tests/loomy.plugin.test.ts @@ -11,6 +11,7 @@ import { SignInExpiredError } from "../src/core/errors.ts" import type { ConfigProvider, Hooks, ProviderInfo } from "../src/core/types.ts" import { forgetDailyBalances, rememberDailyBalance } from "../src/vendors/loomy/balance.ts" import { LoomyAuthPlugin } from "../src/vendors/loomy/index.ts" +import { forgetPetWorkReadings } from "../src/vendors/loomy/petwork.ts" import { forgetTaskReadings } from "../src/vendors/loomy/tasks.ts" import { cannedFetch, fakeClient, jsonFail, jsonOk, providerOf, rejectionError } from "./helpers.ts" @@ -393,6 +394,120 @@ describe("checkin 的接线", () => { }) }) +describe("宠物打工的接线", () => { + const DAILY = jsonOk({ + code: "000000", + desc: "成功", + data: { alreadyProcessed: false, dailyQuota: 5000, dailyConsumed: 1000, dailyBalance: 4000 }, + }) + const ALL_TASKS = jsonOk({ + code: "000000", + desc: "成功", + data: { + tasks: { + first_message: true, + pick_skill: true, + generate_ppt: true, + set_schedule: true, + install_skill: true, + configure_remote: true, + create_soul: true, + share_soul: true, + }, + earned: 10_000, + total: 10_000, + }, + }) + const SNAPSHOT = ( + earned: number, + rewarded: string[], + tiers: { tierId: string; score: number }[] = [ + { tierId: "tier_1", score: 60 }, + { tierId: "tier_2", score: 100 }, + { tierId: "tier_3", score: 150 }, + ], + ) => + jsonOk({ + code: "000000", + desc: "成功", + data: { + enabled: true, + businessDate: "2026-10-23", + config: { version: "pet-work-v1", dailyMaxPoints: 1000, tiers }, + status: { earnedPointsToday: earned, rewardedTierIds: rewarded, dailyRewardCapped: false }, + }, + }) + + async function withFetch(fetcher: ReturnType, run: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = fetcher + try { + return await run() + } finally { + globalThis.fetch = original + } + } + + test("签到顺手领掉已达档位的宠物打工奖励,分不进 credit", async () => { + const fetcher = cannedFetch([ + { + match: "/pet-work/rewards", + respond: jsonOk({ + code: "000000", + desc: "成功", + data: { + businessDate: "2026-10-23", + grantBatchId: "pwg_1", + granted: true, + duplicate: false, + grantedPoints: 200, + grantedTierIds: ["tier_1"], + status: { earnedPointsToday: 200, rewardedTierIds: ["tier_1"], dailyRewardCapped: false }, + }, + }), + }, + { match: "/pet-work", respond: SNAPSHOT(0, [], [{ tierId: "tier_1", score: 60 }]) }, + { match: "/onboarding/tasks", respond: ALL_TASKS }, + { match: "/points/first-login", respond: DAILY }, + ]) + const hooks = await loomyHooks() + const checkin = hooks.auth.checkin + if (checkin === undefined) throw new Error("loomy 必须有 checkin 钩子") + const result = await withFetch(fetcher, () => checkin(async () => AUTH, PROVIDER)) + + // credit 只算当日额度(5000−1000);宠物打工的 200 分是另一个池子。 + expect(result.credit).toBe(4000) + expect(result.message).toContain("宠物打工奖励 +200 分") + expect(fetcher.callsMatching("/pet-work/rewards")).toHaveLength(1) + }) + + test("额度卡片多一行 aside 的宠物打工进度", async () => { + forgetTaskReadings() + forgetPetWorkReadings() + const fetcher = cannedFetch([ + { + match: "/points/records", + respond: jsonOk({ + code: "000000", + data: { balance: 500, dailyBalance: 4000, availableBalance: 4500 }, + }), + }, + { match: "/onboarding/tasks", respond: ALL_TASKS }, + { match: "/pet-work", respond: SNAPSHOT(300, ["tier_1"]) }, + ]) + const hooks = await loomyHooks({ claimTasks: false }) + const usage = hooks.auth.usage + if (usage === undefined) throw new Error("loomy 必须有 usage 钩子") + const report = await withFetch(fetcher, () => + usage(async () => ({ ...AUTH, dailyQuota: 5000, dailyCycleDate: "2026-10-23" }), PROVIDER), + ) + const petwork = report.windows?.find((window) => window.name === "宠物打工") + expect(petwork?.aside).toBe(true) + expect(petwork?.display).toBe("宠物打工 今日 300/1000 · 已领 1/3 档") + expect(report.windows?.map((window) => window.name)).toStrictEqual(["今日积分", "永久积分", "宠物打工"]) + }) +}) + describe("loader 的门禁接线", () => { test("锁定时今日余额为 0 的请求不下发,直接 429", async () => { forgetDailyBalances() From d94d15d094649fe3683d1bacaf41daf23a939bb7 Mon Sep 17 00:00:00 2001 From: xxhhlk Date: Sat, 24 Oct 2026 18:47:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(loomy):=20=E5=8D=A1=E7=89=87=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E3=80=8C0=20=E5=88=86=E3=80=8D=E5=86=99?= =?UTF-8?q?=E6=88=90=E3=80=8C=E6=9C=AA=E8=BE=BE=E6=A1=A3=E4=BD=8D=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快照里没有「已达档位」这个字段(只有 earnedPointsToday / rewardedTierIds / dailyRewardCapped),0 分只说明今天还没领过。实测 2026-10-24:卡片显示 「宠物打工 今日 0/1000 · 未达档位」,紧接着一次签到领到 +1000 —— 三档其实 早就够了,那一行是在编结论。改成「未领取」,并在函数注释里留下这个反例。 --- docs/vendors.md | 13 +++++++++---- src/vendors/loomy/petwork.ts | 7 ++++++- tests/loomy.petwork.test.ts | 5 +++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/vendors.md b/docs/vendors.md index 40f551e..af76c83 100644 --- a/docs/vendors.md +++ b/docs/vendors.md @@ -293,15 +293,20 @@ index 上限 2 / 2、采样 28 天、最少 7 个活跃日)。 ``` 宠物打工 今日 300/1000 · 已领 1/3 档 -宠物打工 今日 0/1000 · 未达档位 +宠物打工 今日 0/1000 · 未领取 ``` +`0 分` 只写「未领取」,**不写「未达档位」**:快照里没有「已达档位」这个字段(只有 +`earnedPointsToday` / `rewardedTierIds` / `dailyRewardCapped`),够不够档只有领奖回执 +才会说。实测反例(2026-10-24):卡片显示 `0/1000`,紧接着一次签到领到 `+1000`,三档 +其实早就够了 —— 把「没领」写成「没到」就是编结论。 + 封顶(`dailyRewardCapped`)或活动关闭时不显示 —— 与新手任务 8/8 时不显示同一取舍。 不带 `limit` / `amount`,否则卡片会把它画成「池子被花掉多少」的进度条,并让它参与选号。 -**未验证边界**:`granted: true` 的发放分支只在离线测试里按 `grantedPoints` 与 -「今日累计差分」两条路走过;真账号当天已封顶,只实测到 `duplicate: true` 这一种 -回执。首次真实领奖时核对 `grantedPoints` 是否等于 `earnedPointsToday` 的增量。 +**未验证边界**:`granted: true` 的发放分支在 2026-10-24 已实测到(三档一次领完,回执 +合计 `+1000`);仍未见过的是「部分档位已领、下次再领剩余档」的回执形态,这条由离线 +测试覆盖。 ### 上下文窗口与图片能力的硬覆盖(2026-10-22 自 JetHub 移植) diff --git a/src/vendors/loomy/petwork.ts b/src/vendors/loomy/petwork.ts index a1b586a..a600a55 100644 --- a/src/vendors/loomy/petwork.ts +++ b/src/vendors/loomy/petwork.ts @@ -94,6 +94,11 @@ export async function petWorkStateFor( * * `aside: true` 且不带 `limit` / `amount`:这一行只解释今天有没有分可领,不该被 * 画成「池子被花掉多少」的进度条,也不该参与选号(封顶与否不是路由依据)。 + * + * ⚠️ `earnedToday == 0` 只说明**还没领过**,不说明「未达档位」:快照里没有「已达档位」 + * 这个字段(只有 `earnedPointsToday` / `rewardedTierIds` / `dailyRewardCapped`), + * 够不够档只有领奖回执才会说。写「未达档位」是拿没证据的结论骗人 —— 实测过:卡片显示 + * 0/1000「未达档位」,紧接着签到领到 +1000,说明三档早就够了。 */ export function petWorkWindow(state: LoomyPetWork | undefined): UsageWindow | undefined { if (state === undefined || !state.enabled || state.capped) return undefined @@ -101,7 +106,7 @@ export function petWorkWindow(state: LoomyPetWork | undefined): UsageWindow | un const parts = [state.dailyMax === undefined ? `今日 ${earned} 分` : `今日 ${earned}/${state.dailyMax}`] if (earned > 0 && state.tiers.length > 0) parts.push(`已领 ${state.rewardedTiers.length}/${state.tiers.length} 档`) - if (earned === 0) parts.push("未达档位") + if (earned === 0) parts.push("未领取") return { name: "宠物打工", used: 0, diff --git a/tests/loomy.petwork.test.ts b/tests/loomy.petwork.test.ts index ed01c61..87a04be 100644 --- a/tests/loomy.petwork.test.ts +++ b/tests/loomy.petwork.test.ts @@ -314,7 +314,7 @@ describe("pet-work 领奖", () => { }) describe("宠物打工的卡片行", () => { - test("一分没有时说清原因,且不参与选号", () => { + test("一分没有时只说「未领取」,不猜「未达档位」", () => { const window = petWorkWindow({ enabled: true, businessDate: "2026-10-23", @@ -328,7 +328,8 @@ describe("宠物打工的卡片行", () => { }) expect(window?.aside).toBe(true) expect(window?.used).toBe(0) - expect(window?.display).toBe("宠物打工 今日 0/1000 · 未达档位") + // 快照没有「已达档位」字段:0 分只能说明还没领,说「未达档位」就是编结论。 + expect(window?.display).toBe("宠物打工 今日 0/1000 · 未领取") // 不带 limit/amount:卡片不该把它画成「池子被花掉多少」。 expect(window?.limit).toBeUndefined() expect(window?.amount).toBeUndefined()