f4-growth.mjs
10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#!/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)
})