diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index 7323788..2688ecd 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -17,6 +17,7 @@ "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", + "verify:f4": "node tools/scenarios/voko-alpha/f4-growth.mjs", "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs", "verify:webhook": "node tools/scenarios/voko-alpha/webhook.mjs", "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", diff --git a/apps/voko-server/src/assembly/email.js b/apps/voko-server/src/assembly/email.js index 28162e1..cfee7da 100644 --- a/apps/voko-server/src/assembly/email.js +++ b/apps/voko-server/src/assembly/email.js @@ -83,12 +83,13 @@ export function createEmailPorts({ env, db, logger }) { * 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。 * @param {string} user_id * @param {string} purpose - * @param {{ kind: 'claimed'|'help', path: string, agent: string, task?: string, question?: string }} notice + * @param {{ kind: 'claimed'|'help'|'digest', path: string, agent?: string, task?: string, question?: string, summary?: (locale: 'en'|'zh') => string }} notice `summary` 按主人的语言生成正文 */ async function sendOwnerNotice(user_id, purpose, { path, ...notice }) { const owner = await db('users').where({ id: user_id }).first('email', 'locale') if (owner === undefined) throw new Error(`users row missing for owner notice, id: ${user_id}`) - const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale: String(owner.locale).startsWith('zh') ? 'zh' : 'en', url: `${env.publicUrl}${path}`, ...notice }) + const locale = String(owner.locale).startsWith('zh') ? 'zh' : 'en' + const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale, url: `${env.publicUrl}${path}`, ...notice, ...(notice.summary === undefined ? {} : { summary: notice.summary(locale) }) }) await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' }) } @@ -114,6 +115,23 @@ export function createEmailPorts({ env, db, logger }) { async sendHelp(_ctx, { user_id, agent_name, task_title, question }) { await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question }) }, + /** + * 日报。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。 + * @param {object} _ctx + * @param {{ user_id: string, digest: { open_help: number, agents: Array<{ name: string, level: number, settled: number, points: number, active_tasks: number }> } }} input + */ + async sendDigest(_ctx, { user_id, digest }) { + await sendOwnerNotice(user_id, 'daily_digest', { + kind: 'digest', + path: '/agents', + summary: (locale) => [ + ...digest.agents.map((agent) => (locale === 'zh' + ? `${agent.name}(${agent.level} 级):结算 ${agent.settled} 笔,赚取 ${agent.points} 积分,在手任务 ${agent.active_tasks} 个` + : `${agent.name} (level ${agent.level}): ${agent.settled} payouts, ${agent.points} points earned, ${agent.active_tasks} tasks in hand`)), + locale === 'zh' ? `等你回复的求助:${digest.open_help} 条` : `Help requests waiting for you: ${digest.open_help}`, + ].join('\n'), + }) + }, } return { authEmail, accessNotifier, ownerNotifier } diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index 59e2856..9c5cb3d 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { createLLM, createStaticKeyProvider } from '@drama/llm' import { createLogger } from '@drama/observability' -import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime } from '@voko/access' +import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' import { createEscrow, createTips, verifyConservation } from '@voko/clearing' -import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' +import { createAgents, createEvents, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' import { readEnv } from '../env.js' import { createEmailPorts } from './email.js' @@ -69,7 +69,41 @@ export async function createRuntime({ service }) { }) const tips = createTips({ db }) const webhooks = createWebhooks({ db, secret_key: env.webhookSecretKey }) - const jobs = createJobs({ db, tasks, help, conservationProblems: () => verifyConservation(db), logger }) + const game = createGame({ db }) + + /** + * 给主人发日报:过去 24 小时没有任何动静(无结算、无在手任务、无待回复求助)就不打扰。 + * @param {object} ctx + * @param {{ user_id: string }} input + */ + async function sendDigest(ctx, { user_id }) { + const digest = await game.digestFor(ctx, { owner_user_id: user_id, since: new Date(Date.now() - 86400_000) }) + const quiet = digest.open_help === 0 && digest.agents.every((agent) => agent.settled === 0 && agent.active_tasks === 0) + if (!quiet) await ownerNotifier.sendDigest(ctx, { user_id, digest }) + } + const jobs = createJobs({ db, tasks, help, sendDigest, runDailyGame: (ctx) => game.runDaily(ctx), conservationProblems: () => verifyConservation(db), logger }) + + /** + * 给每个有 Agent 且开着日报的主人排下一次日报(当地 09:00)。重复调用无害。 + * @param {object} ctx + */ + async function scheduleDigests(ctx) { + const now = new Date() + const owners = await db('vk_agents').whereNotNull('owner_user_id').distinct('owner_user_id') + for (const { owner_user_id } of owners) { + const settings = await ownerSettings.get(ctx, { user_id: owner_user_id }) + if (settings.email_digest) await jobs.enqueue(ctx, { kind: 'daily_digest', ref_id: owner_user_id, run_at: nextLocalHour(now, settings.timezone, 9) }) + } + } + + /** + * 运营给公会预算调账:记到公司 Agent 的钱包,reason=admin_adjustment,note 必填。 + * @param {{ wallet_user_id: string, amount: number, note: string, idempotency_key: string }} input + */ + async function grantBudget({ wallet_user_id, amount, note, idempotency_key }) { + return createLedger(createBillingRepo(db)).grant({ user_id: wallet_user_id, amount, source: 'admin', reason: 'admin_adjustment', note, idempotency_key: `vk:budget:${idempotency_key}` }) + } + /** * 给已结算任务的赢家打赏;收款事件与动账同事务。 @@ -135,7 +169,7 @@ export async function createRuntime({ service }) { return { env, logger, - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, game, sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/db/migrations/202610100004_titles_and_guild_goal.js b/apps/voko-server/src/db/migrations/202610100004_titles_and_guild_goal.js new file mode 100644 index 0000000..bcf55cf --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100004_titles_and_guild_goal.js @@ -0,0 +1,23 @@ +/** W5:称号 vk_agent_titles;公会周目标与等级(weekly_goal 为本周结算积分目标,0 表示未设)。与 tables/*.cjs 逐字一致。 */ +export async function up(db) { + await db.schema.createTable('vk_agent_titles', (t) => { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('title', 32).notNullable() + t.string('period_key', 16).notNullable() + t.datetime('awarded_at', { precision: 3 }).notNullable() + t.unique(['agent_id', 'title', 'period_key'], { indexName: 'uq_vk_agent_titles_once' }) + t.check("title in ('first_relay','streak_7','weekly_mvp')", [], 'ck_vk_agent_titles_title') + }) + + await db.schema.alterTable('vk_guilds', (t) => { + t.bigInteger('weekly_goal').unsigned().notNullable().defaultTo(0) + t.integer('level').unsigned().notNullable().defaultTo(1) + t.string('goal_reached_week', 10).nullable() + }) +} + +export async function down(db) { + await db.schema.alterTable('vk_guilds', (t) => t.dropColumns('weekly_goal', 'level', 'goal_reached_week')) + await db.schema.dropTable('vk_agent_titles') +} diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js index 49fb2ba..4e8606b 100644 --- a/apps/voko-server/src/routes/owner.js +++ b/apps/voko-server/src/routes/owner.js @@ -1,7 +1,7 @@ import * as v from 'valibot' import { codedError, requireUser } from './auth.js' -import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' +import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) @@ -18,6 +18,10 @@ const SettingsBody = v.object({ email_help: v.boolean(), email_digest: v.boolean(), }) +const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) +const BudgetBody = v.object({ amount: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)), note: v.pipe(v.string(), v.minLength(1), v.maxLength(500)) }) +const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) }) +const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) /** @@ -27,7 +31,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) * @param {Record} services */ export function registerOwnerRoutes(app, services) { - const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services + const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, game, db } = services /** * 业务拒绝 → 网页接口的错误形状。 @@ -160,6 +164,82 @@ export function registerOwnerRoutes(app, services) { return result.ok ? { task: taskView(result.task) } : refused(reply, result) }) + // 公会城、动态与排行榜:以自己某个 Agent 的身份看它所在的公会。 + app.get('/api/voko/agents/:id/world/state', async (request) => { + const { agent } = await ownAgent(request) + return game.worldState(request.ctx, { guild_id: agent.guild_id }) + }) + + app.get('/api/voko/agents/:id/world/feed', async (request) => { + const { agent } = await ownAgent(request) + const query = v.parse(FeedQuery, request.query) + return { events: (await game.worldFeed(request.ctx, { guild_id: agent.guild_id, since: query.since })).map(feedView) } + }) + + app.get('/api/voko/agents/:id/leaderboard', async (request) => { + const { agent } = await ownAgent(request) + const query = v.parse(LeaderboardQuery, request.query) + return { ...query, leaderboard: await game.leaderboard(request.ctx, { agent, ...query }) } + }) + + /** @param {import('fastify').FastifyRequest} request */ + async function requireAdmin(request) { + const user = await requireUser(request, services) + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') + return user + } + + // 运营:公会与预算。预算记在公司 Agent 的钱包里,走人工调账。 + app.get('/api/voko/admin/guilds', async (request) => { + await requireAdmin(request) + const rows = await guilds.listForAdmin(request.ctx) + return { + guilds: await Promise.all(rows.map(async ({ company_wallet_user_id, ...guild }) => ({ + ...guild, + require_lead_approval: guild.require_lead_approval === 1, + budget: company_wallet_user_id === null ? null : await escrow.walletOf(request.ctx, { wallet_user_id: company_wallet_user_id }), + }))), + } + }) + + app.post('/api/voko/admin/guilds/:id/budget', async (request) => { + await requireAdmin(request) + const params = v.parse(GuildParams, request.params) + const body = v.parse(BudgetBody, request.body) + const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) + const guild = (await guilds.listForAdmin(request.ctx)).find((item) => item.id === params.id) + if (guild === undefined) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND') + if (guild.company_wallet_user_id === null) throw codedError('This guild has no company agent yet; its lead must claim one first', 'CONFLICT') + await services.grantBudget({ wallet_user_id: guild.company_wallet_user_id, amount: body.amount, note: body.note, idempotency_key: key }) + return { budget: await escrow.walletOf(request.ctx, { wallet_user_id: guild.company_wallet_user_id }) } + }) + + app.put('/api/voko/admin/guilds/:id/weekly-goal', async (request) => { + await requireAdmin(request) + const params = v.parse(GuildParams, request.params) + const body = v.parse(GoalBody, request.body) + if (!(await guilds.setWeeklyGoal(request.ctx, { guild_id: params.id, weekly_goal: body.weekly_goal }))) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND') + return { ok: true } + }) + + // 运营:数据看板;手动触发每日称号结算与某位主人的日报(平时由 worker 定时执行)。 + app.get('/api/voko/admin/dashboard', async (request) => { + await requireAdmin(request) + return { dashboard: await game.dashboard(request.ctx) } + }) + + app.post('/api/voko/admin/game/run-daily', async (request) => { + await requireAdmin(request) + return { awarded: await game.runDaily(request.ctx) } + }) + + app.post('/api/voko/admin/digests/:user_id/send', async (request) => { + await requireAdmin(request) + const params = v.parse(UserParams, request.params) + await services.sendDigest(request.ctx, { user_id: params.user_id }) + return { ok: true } + }) + // 运营:仲裁队列与裁决。 app.get('/api/voko/admin/disputes', async (request) => { const user = await requireUser(request, services) diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index aed7f8b..af131d7 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -53,6 +53,8 @@ const ReviewBody = v.object({ const HelpBody = v.object({ question: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const TipBody = v.object({ amount: Reward }) const LedgerQuery = v.object({ limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50') }) +export const LeaderboardQuery = v.object({ scope: v.optional(v.picklist(['guild', 'world']), 'guild'), period: v.optional(v.picklist(['week', 'all']), 'week') }) +export const FeedQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) @@ -70,7 +72,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. * @param {Record} services */ export function registerV1Routes(app, services) { - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, rateLimiter } = services + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, rateLimiter } = services app.register(async (v1) => { v1.setErrorHandler((/** @type {any} */ err, request, reply) => { @@ -164,6 +166,25 @@ export function registerV1Routes(app, services) { v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id }))) + v1.get('/leaderboard', async (request) => { + const query = v.parse(LeaderboardQuery, request.query) + return ok({ ...query, leaderboard: await game.leaderboard(request.ctx, { agent: agentOf(request), ...query }) }) + }) + + // 公会城只读接口:只能看自己公会(未认领的 Agent 没有公会)。 + v1.get('/world/state', async (request, reply) => { + const agent = agentOf(request) + if (agent.guild_id === null) return fail(reply, 403, 'forbidden', 'Agent is not a member of any guild', 'Ask your human to claim you first.') + return ok(await game.worldState(request.ctx, { guild_id: agent.guild_id })) + }) + + v1.get('/world/feed', async (request, reply) => { + const agent = agentOf(request) + if (agent.guild_id === null) return fail(reply, 403, 'forbidden', 'Agent is not a member of any guild', 'Ask your human to claim you first.') + const query = v.parse(FeedQuery, request.query) + return ok({ events: (await game.worldFeed(request.ctx, { guild_id: agent.guild_id, since: query.since })).map(feedView) }) + }) + v1.get('/wallet/ledger', async (request) => { const query = v.parse(LedgerQuery, request.query) const rows = await escrow.ledgerOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id, limit: query.limit }) @@ -344,6 +365,20 @@ function commentView(comment) { } /** + * 公会动态的对外形状:事件类型、时间、当事 Agent 与任务概况,供前端翻译成动画。 + * @param {Record} event + */ +export function feedView(event) { + return { + id: event.id, + type: event.type, + at: event.created_at.toISOString(), + agent_id: event.target_agent_id, + task: event.task_id === null ? null : { id: event.task_id, title: event.title, reward: event.reward, rarity: event.rarity, holder_agent_id: event.holder_agent_id, publisher_agent_id: event.publisher_agent_id }, + } +} + +/** * 流水的对外形状:任务相关的流水带 task_id。 * @param {{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }} row */ diff --git a/apps/voko-server/src/voko-worker-main.js b/apps/voko-server/src/voko-worker-main.js index 23ecf9c..044511c 100644 --- a/apps/voko-server/src/voko-worker-main.js +++ b/apps/voko-server/src/voko-worker-main.js @@ -15,7 +15,10 @@ async function tick() { await runtime.services.jobs.runDue({}) await runtime.services.webhooks.deliverDue({}) ticks += 1 - if (ticks % HEARTBEAT_EVERY_TICKS === 1) await runtime.services.jobs.ensureDaily({}) + if (ticks % HEARTBEAT_EVERY_TICKS === 1) { + await runtime.services.jobs.ensureDaily({}) + await runtime.services.scheduleDigests({}) + } if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index b7c26d6..f351201 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -170,11 +170,13 @@ function createWorld(name, db) { if (agentIds.length > 0) { await db('billing_ledger').whereIn('user_id', wallets).del() await db('vk_api_keys').whereIn('agent_id', agentIds).del() + await db('vk_agent_titles').whereIn('agent_id', agentIds).del() await db('vk_agent_claims').whereIn('agent_id', agentIds).del() await db('vk_agents').whereIn('id', agentIds).del() await db('users').whereIn('id', wallets).del() } if (userIds.length > 0) { + await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del() await db('vk_access_requests').whereIn('user_id', userIds).del() await db('vk_owner_settings').whereIn('user_id', userIds).del() await db('auth_sessions').whereIn('user_id', userIds).del() diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs new file mode 100644 index 0000000..7459756 --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * F4 成长与游戏层的真实链路脚本(Planning 用例 F4-02、03、04、05)。F4-01 公会城画面为网页,手工走查。 + */ +import { randomUUID } from 'node:crypto' + +import { runScenario } from './_lib.mjs' + +await runScenario('f4', async ({ db, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, publish, wallet, submit, claim, review }) => { + const alice = await createOwner('alice') + const bob = await createOwner('bob') + const carol = await createOwner('carol') + const guild = await createGuild('main', alice.user_id) + const other = await createGuild('other', carol.user_id) + const company = await createAgent('company', alice, guild, 'company') + const taker = await createAgent('taker', bob, guild) + const helper = await createAgent('helper', carol, guild) + const outsider = await createAgent('outsider', carol, other) + const adminToken = await admin() + const me = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data + const eventsOf = async (agent) => (await call('GET', '/v1/events?since=0', { token: agent.key })).json.data.events + /** 发布 → 接单方认领、提交 → 发布方通过。 */ + const deliver = async (agent, overrides) => { + const task = (await publish(company, overrides)).json.data + await claim(agent, task.id) + await submit(agent, task.id) + const accepted = await review(company, task.id, 'accept', ['c1']) + check(`结算 ${task.reward} 分给 ${agent.id.slice(0, 11)}`, accepted.status === 200 && accepted.json.data.status === 'settled', accepted) + return task + } + + // 运营后台:公会预算走人工调账,周目标可配置。 + const budgetKey = randomUUID() + const granted = await call('POST', `/api/voko/admin/guilds/${guild}/budget`, { token: adminToken, idempotency_key: budgetKey, body: { amount: 1000, note: 'f4 scenario budget' } }) + const replayed = await call('POST', `/api/voko/admin/guilds/${guild}/budget`, { token: adminToken, idempotency_key: budgetKey, body: { amount: 1000, note: 'f4 scenario budget' } }) + check('运营给公会预算调账 1000,重放不重复', granted.status === 200 && granted.json.budget.available === 1100 && replayed.json.budget.available === 1100, { granted, replayed }) + const noCompany = await call('POST', `/api/voko/admin/guilds/${other}/budget`, { token: adminToken, idempotency_key: randomUUID(), body: { amount: 10, note: 'x' } }) + const notAdmin = await call('GET', '/api/voko/admin/guilds', { token: alice.token }) + check('没有公司 Agent 的公会不能调预算 → 409;非运营看后台 → 403', noCompany.status === 409 && notAdmin.status === 403, { noCompany, notAdmin }) + check('设置公会周目标 150', (await call('PUT', `/api/voko/admin/guilds/${guild}/weekly-goal`, { token: adminToken, body: { weekly_goal: 150 } })).status === 200) + const listed = (await call('GET', '/api/voko/admin/guilds', { token: adminToken })).json.guilds.find((item) => item.id === guild) + check('后台公会列表:公司 Agent、预算、周目标', listed.company_agent_id === company.id && listed.budget.available === 1100 && listed.weekly_goal === 150 && listed.level === 1, listed) + + // F4-02 结算后 XP 增加、升级事件、解锁更高稀有度。 + const rare = (await publish(company, { reward: 250 })).json.data + const locked = await claim(taker, rare.id) + check('1 级接不了稀有度 4 的任务 → 422', rare.rarity === 4 && locked.status === 422, { rare, locked }) + await deliver(taker, { reward: 120 }) + const leveled = await me(taker) + check('F4-02 结算后 XP = 120,升到 2 级,收到 agent.level_up', leveled.xp === 120 && leveled.level === 2 && (await eventsOf(taker)).some((e) => e.type === 'agent.level_up'), leveled) + const unlocked = await claim(taker, rare.id) + check('F4-02 升级后解锁稀有度 4 的任务', unlocked.status === 200, unlocked) + + // F4-04 公会周目标:本周结算积分达到目标 → 公会升级。 + const beforeGoal = (await call('GET', '/v1/world/state', { token: taker.key })).json.data + check('周目标进度 = 本周已结算赏金 120,公会仍为 1 级', beforeGoal.guild.weekly_progress === 120 && beforeGoal.guild.weekly_goal === 150 && beforeGoal.guild.level === 1, beforeGoal.guild) + + // F4-03 称号:首次接力成功。helper 留检查点后放弃,taker 接力完成。 + const relay = (await publish(company, { reward: 60 })).json.data + await claim(helper, relay.id) + await call('POST', `/v1/tasks/${relay.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } }) + await call('POST', `/v1/tasks/${relay.id}/release`, { token: helper.key }) + await claim(taker, relay.id) + await submit(taker, relay.id) + await review(company, relay.id, 'accept', ['c1']) + const state = (await call('GET', '/v1/world/state', { token: taker.key })).json.data + const titled = (id) => state.agents.find((agent) => agent.id === id).titles + check('F4-03 接力双方都获得 first_relay 称号并收到事件', titled(taker.id).includes('first_relay') && titled(helper.id).includes('first_relay') && titled(company.id).length === 0 && (await eventsOf(helper)).some((e) => e.type === 'agent.title_awarded'), state.agents) + const titlesBefore = Number((await db('vk_agent_titles').whereIn('agent_id', [taker.id, helper.id]).count({ n: '*' }).first()).n) + await deliver(taker, { reward: 10 }) + const daily = await call('POST', '/api/voko/admin/game/run-daily', { token: adminToken }) + const titlesAfter = Number((await db('vk_agent_titles').whereIn('agent_id', [taker.id, helper.id]).count({ n: '*' }).first()).n) + check('称号只发一次;每日称号结算可重复执行', titlesAfter === titlesBefore && daily.status === 200 && typeof daily.json.awarded.streak_7 === 'number', { titlesBefore, titlesAfter, daily }) + check('F4-04 周目标达成(120 + 60 + 10 ≥ 150)→ 公会升到 2 级,发 guild.goal_progress', state.guild.level === 2 && state.guild.weekly_progress === 180 && (await eventsOf(company)).filter((e) => e.type === 'guild.goal_progress').length === 1, state.guild) + + // F4-04 排行榜与流水一致。 + const board = (await call('GET', '/v1/leaderboard?scope=guild&period=week', { token: taker.key })).json.data.leaderboard + const earnedFromLedger = async (agent) => (await call('GET', '/v1/wallet/ledger?limit=100', { token: agent.key })).json.data.ledger.filter((row) => ['vk_task_payout', 'vk_relay_share'].includes(row.reason)).reduce((sum, row) => sum + row.amount, 0) + check('F4-04 本周排行与流水一致:taker 160 第一,helper 30 第二', board.length === 2 && board[0].agent_id === taker.id && board[0].points === 160 && board[0].points === (await earnedFromLedger(taker)) && board[1].agent_id === helper.id && board[1].points === 30 && board[1].points === (await earnedFromLedger(helper)) && board[0].rank === 1, board) + const world = (await call('GET', '/v1/leaderboard?scope=world&period=all', { token: outsider.key })).json.data.leaderboard + check('世界榜包含其他公会的 Agent', world.some((row) => row.agent_id === taker.id), world.slice(0, 3)) + check('钱包与 XP 对得上:taker 100 + 160,XP 160', (await wallet(taker)).available === 260 && (await me(taker)).xp === 160, await wallet(taker)) + + // F4-04 日报邮件。 + const digest = await call('POST', `/api/voko/admin/digests/${bob.user_id}/send`, { token: adminToken }) + const digestMail = await waitForEmail({ to_email: bob.email, purpose: 'daily_digest', status: 'sent' }) + check('F4-04 日报邮件已投递', digest.status === 200 && digestMail.template === 'voko-notice', digestMail) + + // F4-05 公会城只读接口:成员可读,未认领的 Agent 403,只能看到自己公会。 + const feed = await call('GET', '/v1/world/feed?since=0', { token: helper.key }) + const types = feed.json.data.events.map((event) => event.type) + check('F4-05 成员可读动态:含认领、通过、升级、称号、周目标,同一事件不重复', feed.status === 200 && ['task.published', 'task.claimed', 'task.accepted', 'agent.level_up', 'agent.title_awarded', 'guild.goal_progress'].every((type) => types.includes(type)) && new Set(feed.json.data.events.map((event) => event.id)).size === feed.json.data.events.length && feed.json.data.events.find((event) => event.type === 'task.claimed').task.title.length > 0, types) + const later = await call('GET', `/v1/world/feed?since=${feed.json.data.events.at(-1).id}`, { token: helper.key }) + check('since 游标之后没有旧事件', later.json.data.events.length === 0, later) + const unclaimed = (await call('POST', '/v1/agents/register', { body: { name: 'f4-unclaimed', skills: [] } })).json.data + const strangerState = await call('GET', '/v1/world/state', { token: unclaimed.api_key }) + const strangerFeed = await call('GET', '/v1/world/feed', { token: unclaimed.api_key }) + await db('vk_api_keys').where({ agent_id: unclaimed.agent_id }).del() + await db('vk_agent_claims').where({ agent_id: unclaimed.agent_id }).del() + const wallets = await db('vk_agents').where({ id: unclaimed.agent_id }).select('wallet_user_id') + await db('vk_agents').where({ id: unclaimed.agent_id }).del() + await db('users').whereIn('id', wallets.map((row) => row.wallet_user_id)).del() + const otherState = await call('GET', '/v1/world/state', { token: outsider.key }) + check('F4-05 非成员(未认领)→ 403;别的公会的 Agent 只看到自己的公会', strangerState.status === 403 && strangerFeed.status === 403 && otherState.json.data.guild.id === other && otherState.json.data.agents.every((agent) => agent.id !== taker.id), { strangerState, otherState }) + + // 数据看板。 + const dashboard = (await call('GET', '/api/voko/admin/dashboard', { token: adminToken })).json.dashboard + check('数据看板有完成任务数、赚取积分、平均交付周期、一次通过率', dashboard.tasks_settled >= 3 && dashboard.points_earned >= 190 && typeof dashboard.avg_delivery_hours === 'number' && dashboard.first_pass_rate > 0 && dashboard.first_pass_rate <= 1, dashboard) +}) diff --git a/packages/email/src/templates/voko-notice.js b/packages/email/src/templates/voko-notice.js index 6e21ac6..ca14041 100644 --- a/packages/email/src/templates/voko-notice.js +++ b/packages/email/src/templates/voko-notice.js @@ -1,19 +1,21 @@ import { EmailError } from '../map-error.js' -/** VOKO 给主人的通知:Agent 已认领、Agent 求助。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */ +/** VOKO 给主人的通知:Agent 已认领、Agent 求助、日报。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */ const COPY = Object.freeze({ en: { claimed: { subject: 'You claimed {agent} on VOKO', heading: '{agent} is now active', body: 'Your agent joined its guild and received 100 starter points. You can manage it, reset its API key or set a daily spend limit from your agents page.', action: 'Open my agents' }, help: { subject: '{agent} needs your help', heading: '{agent} asked for help', body: 'Task: {task}\n\n{question}', action: 'Reply in your inbox' }, + digest: { subject: 'Your VOKO daily digest', heading: 'What your agents did in the last 24 hours', body: '{summary}', action: 'Open my agents' }, }, zh: { claimed: { subject: '你已在 VOKO 认领 {agent}', heading: '{agent} 已激活', body: '你的 Agent 已加入公会并收到 100 启动积分。你可以在「我的 Agent」里管理它、重置 API key 或设置单日支出上限。', action: '打开我的 Agent' }, help: { subject: '{agent} 需要你的帮助', heading: '{agent} 向你求助', body: '任务:{task}\n\n{question}', action: '去收件箱回复' }, + digest: { subject: '你的 VOKO 日报', heading: '过去 24 小时你的 Agent 做了什么', body: '{summary}', action: '打开我的 Agent' }, }, }) /** - * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help', url: string, agent: string, task?: string, question?: string }} data + * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help'|'digest', url: string, agent?: string, task?: string, question?: string, summary?: string }} data */ export function render(data) { if (!data || typeof data !== 'object') fail('data is required') @@ -22,7 +24,7 @@ export function render(data) { const copy = byKind[data.kind] if (!copy) fail(`unsupported kind: ${String(data.kind)}`) const url = requiredUrl(data.url) - const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question)\}/g, (_, key) => requiredText(data[key], key)) + const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question|summary)\}/g, (_, key) => requiredText(data[key], key)) const heading = fill(copy.heading) const body = fill(copy.body) const text = [heading, '', body, '', `${copy.action}: ${url}`].join('\n') diff --git a/packages/email/test/voko-notice.test.js b/packages/email/test/voko-notice.test.js index bf56e36..8b96a4e 100644 --- a/packages/email/test/voko-notice.test.js +++ b/packages/email/test/voko-notice.test.js @@ -13,7 +13,8 @@ describe('voko-notice 模板', () => { it('缺字段、未知类型、非 https 链接直接报错', () => { expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'help', agent: 'Scout', url: 'https://x.test' })).toThrow(/missing field "task"/) - expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'digest', agent: 'Scout', url: 'https://x.test' })).toThrow(/unsupported kind/) + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'weekly', agent: 'Scout', url: 'https://x.test' })).toThrow(/unsupported kind/) + expect(renderTemplate('voko-notice', { locale: 'zh', kind: 'digest', summary: 'Scout:结算 2 单', url: 'https://x.test' }).text).toContain('结算 2 单') expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'claimed', agent: 'Scout', url: 'http://evil.test' })).toThrow(/must use https/) }) }) diff --git a/packages/voko-access/src/index.js b/packages/voko-access/src/index.js index 22b61e1..e22096b 100644 --- a/packages/voko-access/src/index.js +++ b/packages/voko-access/src/index.js @@ -10,6 +10,6 @@ export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' -export { nextAllowedSendTime } from './quiet-hours.js' +export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js' export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js' export { default as accessDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-access/src/owner-settings.js b/packages/voko-access/src/owner-settings.js index 9c3e8cb..d5e6fa2 100644 --- a/packages/voko-access/src/owner-settings.js +++ b/packages/voko-access/src/owner-settings.js @@ -1,7 +1,7 @@ const TABLE = 'vk_owner_settings' -/** 没有保存过设置的主人使用的产品默认值:UTC、23:00–08:00 安静、两类邮件都开。 */ -export const DEFAULT_OWNER_SETTINGS = Object.freeze({ timezone: 'UTC', quiet_start_hour: 23, quiet_end_hour: 8, email_help: true, email_digest: true }) +/** 没有保存过设置的主人使用的产品默认值:Asia/Shanghai、23:00–08:00 安静、两类邮件都开。 */ +export const DEFAULT_OWNER_SETTINGS = Object.freeze({ timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8, email_help: true, email_digest: true }) /** * @typedef {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number, email_help: boolean, email_digest: boolean }} OwnerSettings diff --git a/packages/voko-access/src/quiet-hours.js b/packages/voko-access/src/quiet-hours.js index 78663fd..9824f07 100644 --- a/packages/voko-access/src/quiet-hours.js +++ b/packages/voko-access/src/quiet-hours.js @@ -16,3 +16,17 @@ export function nextAllowedSendTime(now, { timezone, quiet_start_hour, quiet_end const secondsLeft = ((quiet_end_hour - hour + 24) % 24) * 3600 - part('minute') * 60 - part('second') return new Date(now.getTime() - now.getMilliseconds() + secondsLeft * 1000) } + +/** + * 该时区下一个 `hour` 点整(严格晚于 now),用于每日定时通知。夏令时的近似同上。 + * @param {Date} now + * @param {string} timezone + * @param {number} hour 0–23 + * @returns {Date} + */ +export function nextLocalHour(now, timezone, hour) { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }).formatToParts(now) + const part = (/** @type {string} */ type) => Number(parts.find((item) => item.type === type).value) + const secondsLeft = ((hour - part('hour') + 24) % 24) * 3600 - part('minute') * 60 - part('second') + return new Date(now.getTime() - now.getMilliseconds() + (secondsLeft <= 0 ? secondsLeft + 86400 : secondsLeft) * 1000) +} diff --git a/packages/voko-access/test/quiet-hours.test.js b/packages/voko-access/test/quiet-hours.test.js index ff2e477..6a8342f 100644 --- a/packages/voko-access/test/quiet-hours.test.js +++ b/packages/voko-access/test/quiet-hours.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { nextAllowedSendTime } from '../src/quiet-hours.js' +import { nextAllowedSendTime, nextLocalHour } from '../src/quiet-hours.js' const night = { timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8 } @@ -26,3 +26,11 @@ describe('安静时段', () => { expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 9, quiet_end_hour: 9 })).toBe(noon) }) }) + +describe('下一个当地整点', () => { + it('当天还没到 → 今天;已过或正好 → 明天', () => { + expect(nextLocalHour(new Date('2026-10-12T00:30:00.000Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-12T01:00:00.000Z')) + expect(nextLocalHour(new Date('2026-10-12T01:00:00.000Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-13T01:00:00.000Z')) + expect(nextLocalHour(new Date('2026-10-12T05:20:10.250Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-13T01:00:00.000Z')) + }) +}) diff --git a/packages/voko-exchange/src/db/tables/index.cjs b/packages/voko-exchange/src/db/tables/index.cjs index fec7cf4..07d9fe5 100644 --- a/packages/voko-exchange/src/db/tables/index.cjs +++ b/packages/voko-exchange/src/db/tables/index.cjs @@ -16,6 +16,7 @@ const tableModules = [ require('./vk_comments.cjs'), require('./vk_events.cjs'), require('./vk_jobs.cjs'), + require('./vk_agent_titles.cjs'), ] const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) diff --git a/packages/voko-exchange/src/db/tables/vk_agent_titles.cjs b/packages/voko-exchange/src/db/tables/vk_agent_titles.cjs new file mode 100644 index 0000000..8c519f0 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_agent_titles.cjs @@ -0,0 +1,13 @@ +const tableName = 'vk_agent_titles' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('title', 32).notNullable() + t.string('period_key', 16).notNullable() + t.datetime('awarded_at', { precision: 3 }).notNullable() + t.unique(['agent_id', 'title', 'period_key'], { indexName: 'uq_vk_agent_titles_once' }) + t.check("title in ('first_relay','streak_7','weekly_mvp')", [], 'ck_vk_agent_titles_title') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_guilds.cjs b/packages/voko-exchange/src/db/tables/vk_guilds.cjs index 6ad5af5..b097e2b 100644 --- a/packages/voko-exchange/src/db/tables/vk_guilds.cjs +++ b/packages/voko-exchange/src/db/tables/vk_guilds.cjs @@ -11,6 +11,9 @@ function buildTable(t) { t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400) t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200) t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400) + t.bigInteger('weekly_goal').unsigned().notNullable().defaultTo(0) + t.integer('level').unsigned().notNullable().defaultTo(1) + t.string('goal_reached_week', 10).nullable() t.datetime('created_at', { precision: 3 }).notNullable() t.datetime('updated_at', { precision: 3 }).nullable() t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' }) diff --git a/packages/voko-exchange/src/game.js b/packages/voko-exchange/src/game.js new file mode 100644 index 0000000..176e167 --- /dev/null +++ b/packages/voko-exchange/src/game.js @@ -0,0 +1,213 @@ +import { randomUUID } from 'node:crypto' + +import { levelForXp, weekStart } from './level.js' +import { insertEvent } from './tasks.js' + +const EARNING_REASONS = Object.freeze(['vk_task_payout', 'vk_relay_share']) +const DAY_MS = 86400_000 + +/** + * 结算后的成长结算,在结算事务里调用:收款人升级、接力称号、公会周目标。 + * @param {import('knex').Knex} trx + * @param {{ id: string, guild_id: string }} task + * @param {string[]} recipient_agent_ids 本次结算的全部收款人(多于 1 人即发生了接力) + * @param {Date} now + */ +export async function growAfterSettle(trx, task, recipient_agent_ids, now) { + for (const agent_id of recipient_agent_ids) { + const agent = await trx('vk_agents').where({ id: agent_id }).first('xp', 'level') + const level = levelForXp(agent.xp) + if (level > agent.level) { + await trx('vk_agents').where({ id: agent_id }).update({ level }) + await insertEvent(trx, task, 'agent.level_up', agent_id, now) + } + if (recipient_agent_ids.length > 1) await awardTitle(trx, task, agent_id, 'first_relay', '', now) + } + const guild = await trx('vk_guilds').where({ id: task.guild_id }).forUpdate().first('weekly_goal', 'goal_reached_week') + const week = weekStart(now).toISOString().slice(0, 10) + if (guild.weekly_goal > 0 && guild.goal_reached_week !== week && (await weeklyProgress(trx, task.guild_id, now)) >= guild.weekly_goal) { + await trx('vk_guilds').where({ id: task.guild_id }).update({ goal_reached_week: week, updated_at: now }).increment('level', 1) + await insertEvent(trx, task, 'guild.goal_progress', null, now) + } +} + +/** + * 发称号;同一 Agent 同一称号同一周期只发一次(UNIQUE 裁决),重复时不再发事件。 + * @param {import('knex').Knex} trx + * @param {{ id: string|null, guild_id: string }} task 事件挂靠的任务;与具体任务无关的称号 id 为 null + * @param {string} agent_id + * @param {'first_relay'|'streak_7'|'weekly_mvp'} title + * @param {string} period_key 一次性称号为空串;周称号为该周周一的日期 + * @param {Date} now + */ +async function awardTitle(trx, task, agent_id, title, period_key, now) { + if ((await trx('vk_agent_titles').where({ agent_id, title, period_key }).first('id')) !== undefined) return + await trx('vk_agent_titles').insert({ id: `ti_${randomUUID()}`, agent_id, title, period_key, awarded_at: now }) + await insertEvent(trx, task, 'agent.title_awarded', agent_id, now) +} + +/** + * 公会本周已结算任务的赏金之和。 + * @param {import('knex').Knex} runner + * @param {string} guild_id + * @param {Date} now + * @returns {Promise} + */ +async function weeklyProgress(runner, guild_id, now) { + const row = await runner('vk_tasks').where({ guild_id, status: 'settled' }).where('settled_at', '>=', weekStart(now)).sum({ points: 'reward' }).first() + return Number(row.points) +} + +/** + * 游戏层的只读视图与每日结算:排行榜、公会城状态与动态、称号、日报数据。全部来自业务表与事件表,不另存一份。 + * @param {{ db: import('knex').Knex }} deps + */ +export function createGame({ db }) { + if (typeof db !== 'function') throw new Error('createGame: db is required') + + /** + * 各 Agent 在时间段内靠任务赚到的积分(任务收入 + 接力分成),与账本流水一致。 + * @param {{ guild_id: string|null, since: Date|null, until: Date|null }} filter + */ + function earnings({ guild_id, since, until }) { + const query = db('billing_ledger as l') + .join('vk_agents as a', 'a.wallet_user_id', 'l.user_id') + .where('l.source', 'voko') + .whereIn('l.reason', EARNING_REASONS) + .groupBy('a.id', 'a.name', 'a.level', 'a.guild_id') + .select('a.id as agent_id', 'a.name', 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points')) + if (guild_id !== null) query.where('a.guild_id', guild_id) + if (since !== null) query.where('l.created_at', '>=', since) + if (until !== null) query.where('l.created_at', '<', until) + return query + } + + /** + * @param {object} _ctx + * @param {{ agent: import('./agents.js').Agent, scope: 'guild'|'world', period: 'week'|'all' }} input + * @returns {Promise>} + */ + async function leaderboard(_ctx, { agent, scope, period }) { + const rows = await earnings({ guild_id: scope === 'guild' ? agent.guild_id : null, since: period === 'week' ? weekStart(new Date()) : null, until: null }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }]).limit(50) + return rows.map((row, index) => ({ rank: index + 1, agent_id: row.agent_id, name: row.name, level: row.level, points: Number(row.points) })) + } + + /** + * 公会城当前状态:公会等级与本周目标进度,各 Agent 的状态(求助中 / 工作中 / 空闲)与称号。 + * @param {object} _ctx + * @param {{ guild_id: string }} input + */ + async function worldState(_ctx, { guild_id }) { + const now = new Date() + const [guild, agents, working, asking, titles] = await Promise.all([ + db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'), + db('vk_agents').where({ guild_id, status: 'active' }).orderBy('created_at', 'asc').select('id', 'name', 'role', 'level', 'xp'), + db('vk_tasks').where({ guild_id }).whereIn('status', ['claimed', 'rework']).select('holder_agent_id'), + db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where({ 't.guild_id': guild_id, 'h.status': 'open' }).select('h.agent_id'), + db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id').where('a.guild_id', guild_id).select('ti.agent_id', 'ti.title'), + ]) + if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) + const busy = new Set(working.map((row) => row.holder_agent_id)) + const stuck = new Set(asking.map((row) => row.agent_id)) + return { + guild: { id: guild.id, name: guild.name, level: guild.level, weekly_goal: guild.weekly_goal, weekly_progress: await weeklyProgress(db, guild_id, now) }, + agents: agents.map((agent) => ({ + ...agent, + state: stuck.has(agent.id) ? 'help' : busy.has(agent.id) ? 'working' : 'idle', + titles: [...new Set(titles.filter((row) => row.agent_id === agent.id).map((row) => row.title))], + })), + } + } + + /** + * 公会动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。 + * @param {object} _ctx + * @param {{ guild_id: string, since: number }} input + */ + async function worldFeed(_ctx, { guild_id, since }) { + const events = await db('vk_events as e') + .leftJoin('vk_tasks as t', 't.id', 'e.task_id') + .where('e.guild_id', guild_id) + .where('e.id', '>', since) + .groupBy('e.type', 'e.task_id', 'e.created_at', 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id') + .orderBy('id', 'asc') + .limit(100) + .select(db.raw('MIN(e.id) as id'), 'e.type', 'e.task_id', 'e.created_at', db.raw('MIN(e.target_agent_id) as target_agent_id'), 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id') + return events.map((event) => ({ ...event, id: Number(event.id) })) + } + + /** + * 每日称号结算:连续 7 天有任务结算(streak_7);上一周各公会赚得最多的 Agent(weekly_mvp)。重复执行无害。 + * @param {object} _ctx + * @returns {Promise<{ streak_7: number, weekly_mvp: number }>} + */ + async function runDaily(_ctx) { + const now = new Date() + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + const streakers = await db('vk_escrow_payouts as p') + .join('vk_agents as a', 'a.wallet_user_id', 'p.recipient_wallet') + .whereIn('p.kind', ['winner', 'relay_share']) + .where('p.created_at', '>=', new Date(today.getTime() - 6 * DAY_MS)) + .groupBy('a.id', 'a.guild_id') + .havingRaw('COUNT(DISTINCT DATE(p.created_at)) >= 7') + .select('a.id as agent_id', 'a.guild_id') + const thisWeek = weekStart(now) + const lastWeek = new Date(thisWeek.getTime() - 7 * DAY_MS) + const earned = await earnings({ guild_id: null, since: lastWeek, until: thisWeek }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }]) + const mvps = new Map() + for (const row of earned) if (!mvps.has(row.guild_id)) mvps.set(row.guild_id, row) + await db.transaction(async (trx) => { + for (const row of streakers) await awardTitle(trx, { id: null, guild_id: row.guild_id }, row.agent_id, 'streak_7', '', now) + for (const row of mvps.values()) await awardTitle(trx, { id: null, guild_id: row.guild_id }, row.agent_id, 'weekly_mvp', lastWeek.toISOString().slice(0, 10), now) + }) + return { streak_7: streakers.length, weekly_mvp: mvps.size } + } + + /** + * 日报数据:主人名下各 Agent 自 `since` 以来的结算数与赚取积分、当前在手任务数、等你回复的求助数。 + * @param {object} _ctx + * @param {{ owner_user_id: string, since: Date }} input + */ + async function digestFor(_ctx, { owner_user_id, since }) { + const agents = await db('vk_agents').where({ owner_user_id }).whereNot({ status: 'pending_claim' }).select('id', 'name', 'wallet_user_id', 'level') + const [earned, active, help] = await Promise.all([ + db('billing_ledger').whereIn('user_id', agents.map((agent) => agent.wallet_user_id)).where({ source: 'voko' }).whereIn('reason', EARNING_REASONS).where('created_at', '>=', since).groupBy('user_id').select('user_id', db.raw('SUM(amount) as points'), db.raw('COUNT(*) as settled')), + db('vk_tasks').whereIn('holder_agent_id', agents.map((agent) => agent.id)).whereIn('status', ['claimed', 'rework', 'submitted']).groupBy('holder_agent_id').select('holder_agent_id', db.raw('COUNT(*) as n')), + db('vk_help_requests').where({ owner_user_id, status: 'open' }).count({ n: '*' }).first(), + ]) + return { + open_help: Number(help.n), + agents: agents.map((agent) => { + const income = earned.find((row) => row.user_id === agent.wallet_user_id) + const holding = active.find((row) => row.holder_agent_id === agent.id) + return { name: agent.name, level: agent.level, settled: income === undefined ? 0 : Number(income.settled), points: income === undefined ? 0 : Number(income.points), active_tasks: holding === undefined ? 0 : Number(holding.n) } + }), + } + } + + /** + * 数据看板(PRD 指标):完成任务数、赚取积分、平均交付周期、一次通过率,以及当前在途与争议数。 + * @param {object} _ctx + */ + async function dashboard(_ctx) { + const [settled, earned, statuses, agents, disputes] = await Promise.all([ + db('vk_tasks').where({ status: 'settled' }).select(db.raw('COUNT(*) as n'), db.raw('SUM(rework_count = 0) as first_pass'), db.raw('AVG(TIMESTAMPDIFF(SECOND, created_at, settled_at)) as avg_cycle_sec')).first(), + db('billing_ledger').where({ source: 'voko' }).whereIn('reason', EARNING_REASONS).sum({ points: 'amount' }).first(), + db('vk_tasks').groupBy('status').select('status', db.raw('COUNT(*) as n')), + db('vk_agents').groupBy('status').select('status', db.raw('COUNT(*) as n')), + db('vk_disputes').whereNot({ status: 'resolved' }).count({ n: '*' }).first(), + ]) + const settledCount = Number(settled.n) + return { + tasks_settled: settledCount, + points_earned: Number(earned.points), + avg_delivery_hours: settledCount === 0 ? null : Math.round(Number(settled.avg_cycle_sec) / 360) / 10, + first_pass_rate: settledCount === 0 ? null : Math.round((Number(settled.first_pass) / settledCount) * 1000) / 1000, + open_disputes: Number(disputes.n), + tasks_by_status: Object.fromEntries(statuses.map((row) => [row.status, Number(row.n)])), + agents_by_status: Object.fromEntries(agents.map((row) => [row.status, Number(row.n)])), + } + } + + return { leaderboard, worldState, worldFeed, runDaily, digestFor, dashboard } +} diff --git a/packages/voko-exchange/src/guilds.js b/packages/voko-exchange/src/guilds.js index 93c2152..86eb129 100644 --- a/packages/voko-exchange/src/guilds.js +++ b/packages/voko-exchange/src/guilds.js @@ -38,5 +38,26 @@ export function createGuilds({ db }) { return db(TABLE).orderBy('name', 'asc').select('id', 'name', 'slug') } - return { create, list } + /** + * 运营视角的公会列表:负责人、公司 Agent 及其钱包、等级与周目标。 + * @param {object} _ctx + */ + async function listForAdmin(_ctx) { + return db(`${TABLE} as g`) + .leftJoin('vk_agents as a', 'a.id', 'g.company_agent_id') + .leftJoin('users as u', 'u.id', 'g.lead_user_id') + .orderBy('g.name', 'asc') + .select('g.id', 'g.name', 'g.slug', 'g.level', 'g.weekly_goal', 'g.require_lead_approval', 'u.email as lead_email', 'a.id as company_agent_id', 'a.name as company_agent_name', 'a.wallet_user_id as company_wallet_user_id') + } + + /** + * @param {object} _ctx + * @param {{ guild_id: string, weekly_goal: number }} input 0 表示不设周目标 + * @returns {Promise} + */ + async function setWeeklyGoal(_ctx, { guild_id, weekly_goal }) { + return (await db(TABLE).where({ id: guild_id }).update({ weekly_goal, updated_at: new Date() })) === 1 + } + + return { create, list, listForAdmin, setWeeklyGoal } } diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js index a2175dd..6146c75 100644 --- a/packages/voko-exchange/src/index.js +++ b/packages/voko-exchange/src/index.js @@ -12,6 +12,8 @@ export { createTasks } from './tasks.js' export { createEvents } from './events.js' export { createJobs } from './jobs.js' export { createHelp } from './help.js' +export { createGame } from './game.js' +export { levelForXp, weekStart, MAX_LEVEL } from './level.js' export { createJudge } from './judge.js' export { rarityForReward, maxRarityForLevel } from './rarity.js' export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' diff --git a/packages/voko-exchange/src/jobs.js b/packages/voko-exchange/src/jobs.js index 8a2c455..3649cf7 100644 --- a/packages/voko-exchange/src/jobs.js +++ b/packages/voko-exchange/src/jobs.js @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' + const JOBS = 'vk_jobs' const BATCH = 20 const LEASE_MS = 60_000 @@ -22,11 +24,13 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) * arbitrate(ctx: object, input: { task_id: string }): Promise, * }, * help: { notify(ctx: object, input: { help_id: string }): Promise }, + * sendDigest(ctx: object, input: { user_id: string }): Promise, + * runDailyGame(ctx: object): Promise, * conservationProblems(): Promise>, * logger: { error(message: string, fields: object): void }, * }} deps */ -export function createJobs({ db, tasks, help, conservationProblems, logger }) { +export function createJobs({ db, tasks, help, sendDigest, runDailyGame, conservationProblems, logger }) { if (typeof db !== 'function') throw new Error('createJobs: db is required') /** @@ -79,8 +83,10 @@ export function createJobs({ db, tasks, help, conservationProblems, logger }) { if (problems.length > 0) throw new Error(`conservation check failed: ${JSON.stringify(problems.slice(0, 20))}`) return } + if (job.kind === 'daily_game') return void await runDailyGame(ctx) if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) const task_id = job.ref_id + if (job.kind === 'daily_digest') return sendDigest(ctx, { user_id: job.ref_id }) if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } }) if (WARN_KINDS.includes(job.kind)) return tasks.warn(ctx, { task_id, kind: job.kind }) if (job.kind === 'transfer_expire') return tasks.expireTransfers(ctx, { task_id }) @@ -98,18 +104,29 @@ export function createJobs({ db, tasks, help, conservationProblems, logger }) { } /** - * 确保下一次每日对账(UTC 01:00)已经排上;重复调用无害(dedupe_key 按日期唯一)。 + * 排一条 job;同一类型、同一对象、同一时刻只排一次(dedupe_key UNIQUE),重复调用无害。 * @param {object} _ctx + * @param {{ kind: string, ref_id: string|null, run_at: Date }} input */ - async function ensureDaily(_ctx) { - const now = new Date() - const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 1)) - if (next <= now) next.setUTCDate(next.getUTCDate() + 1) + async function enqueue(_ctx, { kind, ref_id, run_at }) { await db(JOBS) - .insert({ id: `j_conservation_${next.toISOString().slice(0, 10)}`, kind: 'verify_conservation', ref_id: null, run_at: next, status: 'pending', attempts: 0, dedupe_key: `verify_conservation:${next.toISOString().slice(0, 10)}`, created_at: now }) + .insert({ id: `j_${randomUUID()}`, kind, ref_id, run_at, status: 'pending', attempts: 0, dedupe_key: `${kind}:${ref_id === null ? '' : ref_id}:${run_at.toISOString()}`, created_at: new Date() }) .onConflict('dedupe_key') .ignore() } - return { runDue, ensureDaily } + /** + * 确保下一次每日对账(UTC 01:00)与每日称号结算(UTC 00:10)已经排上。 + * @param {object} ctx + */ + async function ensureDaily(ctx) { + const now = new Date() + for (const [kind, hour, minute] of /** @type {const} */ ([['verify_conservation', 1, 0], ['daily_game', 0, 10]])) { + const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute)) + if (next <= now) next.setUTCDate(next.getUTCDate() + 1) + await enqueue(ctx, { kind, ref_id: null, run_at: next }) + } + } + + return { runDue, enqueue, ensureDaily } } diff --git a/packages/voko-exchange/src/level.js b/packages/voko-exchange/src/level.js new file mode 100644 index 0000000..bcdc891 --- /dev/null +++ b/packages/voko-exchange/src/level.js @@ -0,0 +1,25 @@ +// ponytail: 等级门槛先写成常量(PRD:由运营配置);要在后台改时与稀有度区间一起换成配置表。 +/** 第 n 级(从 1 起)所需的累计 XP。经验 = 结算到手的积分。 */ +const LEVEL_MIN_XP = Object.freeze([0, 100, 300, 600, 1000, 1500, 2100, 2800, 3600, 4500]) + +export const MAX_LEVEL = LEVEL_MIN_XP.length + +/** + * @param {number} xp 非负整数 + * @returns {number} 1 – MAX_LEVEL + */ +export function levelForXp(xp) { + if (!Number.isSafeInteger(xp) || xp < 0) throw new Error(`levelForXp: xp invalid: ${xp}`) + return LEVEL_MIN_XP.findLastIndex((min) => xp >= min) + 1 +} + +/** + * 排行榜与周目标的周:周一 00:00 UTC 起算(每周一重置)。 + * @param {Date} now + * @returns {Date} + */ +export function weekStart(now) { + const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7)) + return day +} diff --git a/packages/voko-exchange/src/tasks.js b/packages/voko-exchange/src/tasks.js index c067f71..1b786e5 100644 --- a/packages/voko-exchange/src/tasks.js +++ b/packages/voko-exchange/src/tasks.js @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' +import { growAfterSettle } from './game.js' import { maxRarityForLevel, rarityForReward } from './rarity.js' import { reviewViolation } from './review.js' import { splitRelayPayout } from './split-relay.js' @@ -246,6 +247,7 @@ export function createTasks({ db, agents, escrow, judge }) { await trx('vk_agents').where({ id: recipient.id }).increment('xp', share.amount) } await escrow.settle(ctx, trx, { task_id: task.id, payouts }) + await growAfterSettle(trx, task, shares.map((share) => share.agent_id), new Date()) } /** @@ -686,7 +688,7 @@ async function acceptanceOf(runner, task_id) { /** * @param {import('knex').Knex} trx - * @param {{ id: string, guild_id: string }} task + * @param {{ id: string|null, guild_id: string }} task 与具体任务无关的事件 id 为 null * @param {string} type * @param {string|null} target_agent_id null 表示发给整个公会 * @param {Date} now diff --git a/packages/voko-exchange/test/level.test.js b/packages/voko-exchange/test/level.test.js new file mode 100644 index 0000000..619a42f --- /dev/null +++ b/packages/voko-exchange/test/level.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { levelForXp, MAX_LEVEL, weekStart } from '../src/level.js' +import { maxRarityForLevel } from '../src/rarity.js' + +describe('SM-03 levelForXp 与稀有度上限的边界值', () => { + it.each([[0, 1], [99, 1], [100, 2], [299, 2], [300, 3], [4499, 9], [4500, 10], [1_000_000, 10]])('xp %i → level %i', (xp, level) => { + expect(levelForXp(xp)).toBe(level) + }) + + it('升级解锁更高稀有度:1 级到 3,2 级到 4,满级封顶 10', () => { + expect(maxRarityForLevel(levelForXp(99))).toBe(3) + expect(maxRarityForLevel(levelForXp(100))).toBe(4) + expect(maxRarityForLevel(levelForXp(4500))).toBe(10) + expect(MAX_LEVEL).toBe(10) + }) + + it('非法 XP 直接 throw', () => { + expect(() => levelForXp(-1)).toThrow(/xp invalid/) + expect(() => levelForXp(1.5)).toThrow(/xp invalid/) + }) +}) + +describe('周起点:周一 00:00 UTC', () => { + it.each([ + ['2026-10-12T00:00:00.000Z', '2026-10-12T00:00:00.000Z'], + ['2026-10-14T15:30:00.000Z', '2026-10-12T00:00:00.000Z'], + ['2026-10-18T23:59:59.999Z', '2026-10-12T00:00:00.000Z'], + ['2026-10-19T00:00:00.000Z', '2026-10-19T00:00:00.000Z'], + ])('%s → %s', (now, start) => { + expect(weekStart(new Date(now))).toEqual(new Date(start)) + }) +})