f4-growth.mjs 10.7 KB
#!/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, registerAgent, 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 registerAgent('unclaimed')
  const strangerState = await call('GET', '/v1/world/state', { token: unclaimed.api_key })
  const strangerFeed = await call('GET', '/v1/world/feed', { token: unclaimed.api_key })
  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 })

  // 公开的小镇视图:不登录就能看到全部公会的 Agent 与动态,但不含任务正文、钱包等。
  const town = await call('GET', '/api/voko/public/world/state')
  const townFeed = await call('GET', '/api/voko/public/world/feed?since=0')
  const townBoard = await call('GET', '/api/voko/public/leaderboard')
  const names = town.json.agents.map((agent) => agent.id)
  check('公开小镇:不登录可见两个公会的 Agent、动态与全镇排行', town.status === 200 && town.json.guild === null && [taker.id, helper.id, outsider.id].every((id) => names.includes(id)) && typeof town.json.agents[0].guild_name === 'string' && townFeed.json.events.some((event) => event.type === 'task.accepted') && townBoard.json.leaderboard.some((row) => row.agent_id === taker.id), { town: town.status, feed: townFeed.status })
  const leaked = JSON.stringify([town.json, townFeed.json, townBoard.json])
  check('公开小镇不暴露钱包行、主人、任务正文与 api_key', !leaked.includes('wallet_user_id') && !leaked.includes('owner_user_id') && !leaked.includes('"spec"') && !leaked.includes('vk_live_'), null)
  const stillPrivate = await call('GET', '/api/voko/agents')
  check('其余网页接口不登录仍是 401', stillPrivate.status === 401, stillPrivate)

  // 数据看板。
  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)
})