「随机抽题」把选题的权力从用户手里拿走:服务端抽一道,一开始只告诉你难度,点「开始做题」才揭题并起计时。照舞萌 DX 的随机段位認定做的挑战模式则是「做完一道立刻再抽一道,一共四道」,另外还有不分段的小随机与大随机段位。
先说完边界:这是自测,不是防作弊。 它唯一能保证的是你没法挑题。详见「自测,不是防作弊」。
四个档位按 Codeforces 官方难度(rating)划分,覆盖 800–2600:
| 档位 | 区间 | 单题限时 |
|---|---|---|
| 初级 | 800–1100 | 30 分钟 |
| 中级 | 1200–1500 | 40 分钟 |
| 上级 | 1600–2000 | 50 分钟 |
| 超上级 | 2100–2600 | 60 分钟 |
区间互不重叠,并集正好覆盖 800–2600(相邻区间之间没有空隙,tests/dan.test.ts 里有断言)。限时随难度递增。
「每日一题」不分档位,从四个区间的并集(800–2600)里抽,限时 45 分钟。
上限为什么卡在 2600:DX Rating 的 T97 曲线在 2000 以上属于外推区(productionReady: false,见 dx-rating.md)。再往上题目数量也少得没法按 rating 均匀抽。
题库里各 rating 的题目数量分布极不均匀:800–899 有 1104 道,900–999 只有 354 道,再往上大致每 100 分 450–540 道。如果直接对题目集合随机挑一道,「初级」会几乎全是 800 分。
所以抽题分两步:先在区间内的 rating 值之间均匀选一个,再在该 rating 的题目里随机选一道(drawDanCandidate)。这样每一档的难度分布是均匀的,与每个 rating 桶里有多少题无关。
CF 题目的官方 rating 都是 100 的倍数(实测全量 11152 道有评级的题去重后只有 28 个取值),所以每一档实际只落在 4–6 个难度级别上:
| 档位 | 题目数 | rating 取值 | 最大桶 | 最小桶 |
|---|---|---|---|---|
| 初级 800–1100 | 2312 | 4 个 | 800 分:1104 道 | 900 分:354 道 |
| 中级 1200–1500 | 1875 | 4 个 | 475 道 | 462 道 |
| 上级 1600–2000 | 2578 | 5 个 | 537 道 | 494 道 |
| 超上级 2100–2600 | 2576 | 6 个 | 477 道 | 347 道 |
也就是说「初级」是四个难度级别各占 1/4,而不是让 1104 道 800 分挤掉 354 道 900 分。每日一题在 800–2600 上是 19 个取值,每档抽到区间上界的概率就是 1/4 到 1/19。
抽题前会剔除三类题:
- 本机提交记录里出现过的 —— 包括只有 WA、没有 AC 的(拿到 WA 也算见过,只排 AC 会让「做过但没做出来」的题反复出现)。
- 归档账号上的提交 —— 这里故意不看
accounts.is_archived,和其他地方「只算当前账号」的口径不一样(B50、题友圈、练习记录都带is_archived=0)。归档的含义是「这个账号不再同步了」(换 handle、换号时旧账号会被归档),不是「那些题我没做过」;带上这个条件的话,换过一次 handle 的人之前 AC 过的题会全部重新变成可抽。 - 正在计时 / 计时过的题 —— 计时可以不经提交直接对题号开始(「专注计时」的正常用法),不看这一处的话段位有可能抽到你手上正在做的那道。
依据是本机数据库,所以同步不完整时仍可能有漏网的(比如刚 AC 还没同步)。这一点在页面上也写明了。
每日一题用 (日期, 用户) 做种子确定性抽取:同一天怎么刷新都是同一道,换一天才换题。随机抽题与挑战模式用 crypto.randomInt,服务端抽、服务端存。
每日一题的难度带以用户当前的等效 Rating 为中心 ±200,而不是从 800–2600 全段乱抽:
- 等效 Rating = B50 榜的平均单题 rating × 50。这是评分模型自己的锚(
rating.ts:「单题 rating × 50 = 等效 CF Rating」)。榜填满 50 格时它与 DX Rating 完全相等;没填满时它仍是诚实的水平估计 —— 直接拿 DX Rating(求和口径)当中心的话,四道 1700 分的题总分只有 ~136,带子会整天贴着 800。 - 中心取整到 100 再定带:一次同步把分数从 1543 推到 1549,今天的题不会跟着换;跨过 1550(取整到 1600)带子才挪一次。这是「按水平定带」的代价,如实写在这里。
- 两端裁进档位并集 [800, 2600]。于是水平很低时带子收敛到 [800, 800](从最简单的抽),很高时收敛到 [2600, 2600] —— 2600 以上是 T97 外推段(
productionReady: false),档位刻意不往上走,每日一题跟同一口径。 - 榜上没有任何可计分的成绩(没 AC 过、或 AC 了但从没填过用时)时退回全段,卡片上说明原因。
- 抽取本身仍是
(日期, 用户)确定性:同一天刷新不变(见上一节)。
页面上把带子说出去:「等效 Rating 2199 → 2000–2400」,用户才知道今天的难度为什么是这个。
「随机抽题」卡片里除了四个档位,还有自定义条件:难度范围(800–3500)+ 标签(多选,含任一)。
- 平台锁定 Codeforces:抽题的前提是有全量题库,现在只有 CF 有(
problemset.problems,11152 道);洛谷/码题集只有提交抓取,没有本地题库,而且洛谷的难度是另一套制。标签数据来自同一份抓取 —— CF 的problemset.problems本来就带每道题的 tags,不新增数据源(缓存键从 v1 升到 v2 就是为了把 tags 带上)。 - 上界放到 3500(题库的真实上界):这是用户自己的明确选择。2600 以上仍是外推段,表单上写明了。
- 标签取并集(「含任一」)而不是交集:「dp AND graphs AND greedy」这种交集在 11000 道的题库里也常常是空的。最多选 8 个;题库里没有的标签在 start 时直接拒(400),不会静默抽错。
- 口径 A 原样适用:条件在 start 时发给服务端,题目照样只在服务端抽;点「开始做题」之前,页面里没有这道题的题号与链接。排除做过的题用同一套
danExcludedProblems。 - 条件落库(
dan_sessions的区间列本来就有,标签进 020 新加的tags_json),并冻结进结算快照 —— 历史记录能按当时的条件解释,「再来一轮 · 自定义」也会带上当时的条件。 - 限时随当题难度走(与随机段位同一套
danLimitForRating),不是统一值。 - 自定义只用于单题模式:挑战(段位認定)的档位是固定口径,不跟自定义条件混。
未开始的题目,题号与链接根本不会离开服务端。 这不是「发到前端再藏起来」,而是服务端在组装响应时就不给:
GET /api/dan与抽题响应里,未 claim 的 stage 只含difficulty,problemId/problemUrl都是null(src/dx/dan.ts的danStageView:只有claimed_at !== null才填)。- 唯一的出口是
POST /api/dan/claim,它同时做三件事:起计时器(practice_timers)、写claimed_at、把problemId与url返回给你。前端拿到之后立刻location.href跳走。
因此右键、F12、查看网络请求、刷新页面都拿不到 —— 那道题的链接在那个时刻还不存在于任何下发数据里。
这条口径也意味着刻意不做这些东西:
- 不屏蔽右键与 F12(屏蔽不掉,而且会干扰正常使用;拦不住的东西不该写成注释里的「保护」)。
- 不做前端混淆或把链接藏在 base64 里(同样是「发下去了」,只是难看)。
tests/dan.test.ts 里有一条断言直接搜序列化后的响应体:claim 之前不得出现 codeforces.com,也不得出现任何 contest:index 形态的题号。API 层另有一条断言覆盖 GET /api/dan。
一轮挑战是 4 道题、逐题限时:
active ──四道全部通关──→ cleared(通过)
│
├──任一道超过限时──→ failed(未通过),本轮立即结束
└──用户主动放弃──→ abandoned
- 第 N 道通关(后台同步发现 AC)后,服务端结算该题,下一道的抽取由
POST /api/dan/next触发。 - 任一道超过限时即判为本轮失败并结束,不会继续抽第 N+1 道。
- 一个用户同时只能有一轮进行中的认定(应用层检查 + 数据库上的 partial unique index
one_active_dan_per_user)。 - 抽了不点也会到期作废(默认 24 小时),不会把唯一的活动轮次永久占住。
挑战模式不给段位名,结算只展示各题结果与总分(各道单题 rating 之和)以及通过/未通过。
这是照舞萌 DX 的规则定的:随机段位認定本来就不给段位名,给段位名的是固定选曲段。所以这里也没有分数段/等级线(AAA、S 之类)—— 只有每题自己的达成率与评级。
两个随机段位,rating 都不分段(取四个难度档的并集 800–2600 整段),都是 4 道、都逐题限时。区别只有抽题分布:
| 抽题分布 | 每个 rating 值的概率 | |
|---|---|---|
| 小随机段位 | 按 rating 均匀:先均匀选 rating 值,再在该值内选一道 | 全部相同(1/19 ≈ 5.3%) |
| 大随机段位 | 按题目均匀:不保证难度分布 | 由该难度的题目数决定 |
用真实题库(800–2600 共 9341 道)实测的差别:
| 指标 | 小随机 | 大随机 |
|---|---|---|
| 单道抽到 800 分的概率 | 5.3% | 11.8% |
| 最冷门难度(2600,347 道)的概率 | 5.3% | 3.7% |
| 期望难度 | 1700 | 1637 |
| 四道全都 ≤1100 | 0.21% | 0.36% |
别对「大随机」期待过头:它在我们的题库里只是把 800 分的概率抬到 2.2 倍、期望难度压低约 63 分,并不会真的「四道全是 800」—— 因为 CF 的 800 分题只占池子的 11.8%,而不是像舞萌的简单曲那样占绝大多数。大随机在南梦宫那边看起来那么极端,是因为那边简单曲的占比本来就更悬殊。
(反直觉的一条:大随机反而更难抽到超上级。小随机里 2100+ 有 6 个值、合计 6/19 = 31.6%;大随机里那几桶在池子里只占 27.6% —— 800 分那一桶把份额吸走了。四道里至少一道超上级:小随机 78%、大随机 72%。)
随机段位的题横跨 800–2600,一个统一限时对两头都不公平(给 800 分 60 分钟、给 2600 分也 60 分钟)。所以随机段位的限时按当题难度落到对应档位:≤1100 → 30 分、1200–1500 → 40 分、1600–2000 → 50 分、2100+ → 60 分。
- 限时在抽到的那一刻写进
dan_stages.limit_seconds(migrations/019),与difficulty一样是快照 —— 之后调档位区间不会把这一轮重新解释一遍。 - 该列为 NULL 的是 019 之前抽出来的行,它们属于「档位统一限时」那一套规则,沿用
dan_sessions.limit_seconds,旧记录的解释一个字都不变。 - 四道限时之和平均约 185 分钟 —— 这是上限,不是预期耗时。
- 某题超时 → 本轮
failed,立即结束。 - 超时那道没有成绩:计时器在超时那一刻被取消(
practice_timers.status='cancelled'),不会生成practice记录,自然也不进 B50。已经通过的那几道不受影响,照常计分。 - 超时时间取的是限时截止那一刻(
claimed_at + limit_seconds),不是「发现超时的那一刻」—— 否则一次晚同步会把用时和分数一起撑大。
第一层是单题结算,在每道题出成绩时弹出:用的就是普通计时练习那张结算皮(public/dx-result.css 的 .dx-result-sheet),显示达成率、此前最佳、评级、用时、判定计数、单题 rating,以及结算那一刻的 DX Rating 总分与增量。这段对比读的是 practice_timers.settlement_json 里冻结的那一份 —— 段位認定不另算一套分。
第二层是整轮结算页,四道打完(或中途失败)后出现,版式照原作的段位認定结算(Process/Course/CourseResultProcess):
- 奶油色纸盘:橙红双圈描边,两侧各一条水引红绳(蝴蝶结 + 竖绳 + 流苏)。
- 逐曲卡:每道一张。左侧一枚八角的「可」印(没通关是「不可」,未开始是灰的);右侧是难度配色的横卡 —— 封面那一格放题号(我们抽的是 CF 题、没有曲绘,放题号比放装饰诚实),然后是
STAGE 0n/ 难度 / 题目名,下面一行「达成率」+ 金色描边的大数字 + 判定(评级,或超时/中断),最右边是LV、DX分数(该题算出的单题 rating)与这道题的限时。 - 底部一行:左边判定框写「合格」/「不合格」与通关题数;中间一颗心;右边「总达成率」与
DX分数。 - 右下角一个「好」按钮,旁边是「再来一轮 · 档位」。
两处刻意的改写:
- 原作那一格写的是段位名(例如「十段」)。随机段位認定不发段位名,所以改成写判定结果「合格」/「不合格」。
- 原作那颗心是剩余生命。我们没有生命值这个概念,用它显示「通关几道」(合格绿、失败灰)。
两处数字口径与原来的对照:原作的「总达成率」是四首歌达成率之和,这里就是各道达成率之和;原作的 DX分数 是四首 DX 分数之和,这里对应各道单题 rating 之和(也就是本轮的 totalRating)。段位名那一格改成写判定结果,所以「总达成率」下面不再有段位线。
对照的素材包元素(用 UnityPy 解 resources.assets 的 GameObject / RectTransform 得到):那枚「可」印是 UI_DNM_Stamp 的 Clear / NoClear,两侧绳子是 UI_DNM_Separator,底部那块是 UI_DNM_DaniData。
贴图没有从素材包里搬:全部用 CSS 重画。游戏美术有版权,打进一个 MIT 仓库、再提到上游是不合适的;这里只沿用版式与构成。盘面用大圆角而不是正圆 —— 四道题卡竖排进正圆,在常见宽度下会挤到不可读。
出成绩之后页面停在结算上,不会自己往下抽:
- 轮询调的是
POST /api/dan/settle—— 只结算、不抽题; - 只有点单题结算上的「抽选下一题」才会调
POST /api/dan/next去抽下一道; - 最后一道的按钮是「查看本轮结算」,点了进整轮结算页。
否则你还在看这一道的成绩,下一道就已经悄悄抽好了,等于把「抽题」这一步藏起来。(tests/dan.test.ts 里有一条断言盯这个:调 settle 之后 dan_stages 不得多出一道。)
如果结算比页面刷新更快(服务端同步任务常常如此),打开页面时会把 90 秒内刚结束的那一轮结算补弹一次,只补一次。
挑战与随机抽题的成绩不是另一套计分:走的就是正常的计时练习那条链。
- 起计时 →
practice_timers;AC 被同步到 → 生成practice记录(timing_source在库里存manual,读取时因为存在对应计时器而被listPractice报成timer)。 - 因此这些记录正常进入 B50 与 DX Rating,可以在 DX Rating 页面看到。
开始做题之后,你照样可以看题解、问 AI、查博客 —— 这一点拦不住。它和「辅助解题」自报标签是同一条现实:本地优先的工具只能记录你的自述,不能证明你的清白。
这个功能能保证的只有一件事:你没法挑题。 抽到什么做什么。
另外,「排除做过的题」依赖本机已同步的提交记录;同步不完整时可能抽到你已经做过的题(包括已经 AC 过的)。
迁移 018-dan.sql、019-dan-random.sql 与 020-dan-custom.sql,两张表:
dan_sessions:一轮认定(tier、kind∈challenge/single/daily、stage_count、limit_seconds、min_rating/max_rating、status∈active/cleared/failed/abandoned、起止时间、total_rating、结算快照settlement_json、tags_json(020 新增,自定义抽题的标签条件,NULL = 无条件))。dan_stages:一道题(session_id、stage_index、problem_id、difficulty、drawn_at、claimed_at、timer_id、outcome∈cleared/timeout/interrupted、seconds、score_json、limit_seconds(019 新增,NULL = 沿用 session 的档位统一限时))。
两个随机段位没有新增 kind:它们沿用 kind='challenge',靠 tier(small_random / big_random)区分。加一个 kind 值要重建整张 dan_sessions(SQLite 改不了 CHECK),而 tier 已经能表达这件事,019 也就只需要一条加列。自定义抽题同理:tier='custom',难度范围复用本来就有的 min_rating / max_rating 两列,020 只为标签加了一列。
两个设计点:
- 难度在抽题时快照进
dan_stages.difficulty,结算时不重新查题库。否则等题库缓存过期或题目 rating 被 CF 修订,历史记录会跟着变。limit_seconds同理。 settlement_json在结束时冻结整轮结果,记录页读的是快照,不是重新算一遍。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/dan?user=&tz= |
抽题面板与记录页。档位表(tiers)、两个随机段位(randomTiers)、标签清单(tags,带题量)、每日一题的难度带(dailyBand)、题库是否就绪、每日一题的难度、进行中的一轮、历史记录 |
| POST | /api/dan/start |
开一轮:{userId, kind, tier?, tz?}。kind 为 daily 时忽略 tier。tier='custom'(仅 single)额外收 minRating / maxRating(800–3500)与 tags(≤8 个,题库里没有的直接 400) |
| POST | /api/dan/claim |
唯一一次下发题号与链接:{userId, sessionId}。幂等,重复点击返回同一道题 |
| POST | /api/dan/settle |
只结算、不抽题:{userId}。轮询用这条,所以出成绩会停在结算页上 |
| POST | /api/dan/next |
结算已同步到的 AC 并抽下一道:{userId, tz?}。由「抽选下一题」触发 |
| POST | /api/dan/abandon |
放弃进行中的一轮:{userId, sessionId} |
五个 POST 都登记在 src/server/api.ts 的 WRITE_ROUTES 里。GET /api/dan 是只读的:它只从 fetch_cache 读题库缓存,读连接是 PRAGMA query_only = ON,所以冷缓存时如实返回 poolReady: false,由前端引导走一次写接口去抓。
错误码:区间里没有没做过的题是 POOL_EMPTY(以 poolEmpty 标记返回,不静默抽区间外的题);已有一轮没结束时 start 返回 409 SESSION_ACTIVE;其余 DanError 一律 400。
复用 problemset.problems 那份 6 小时缓存(cf:problemset-ratings:v2,v2 起连 tags 一起缓存,同一次抓取自带、不额外请求),不新增数据源。实测全量 11433 道题,其中 11152 道(97.5%)有官方 rating,上限 3500,不含 gym。各档位可用题量:初级 2312、中级 1875、上级 2578、超上级 2576;两个随机段位用的并集(800–2600)是 9341 道、19 个 rating 值(每个值都是 100 的倍数,最多的 800 分有 1104 道,最少的 2600 只有 347 道)。标签共 38 个(greedy 3560 / math 3483 / implementation 3020 / dp 2518 …)。
缓存过期时 POST 那几条路径会触发一次抓取(约 1.5 MB),之后 6 小时内复用。v1 老缓存里没有 tags:带标签条件抽题时把它当「没有标签信息」处理(一律不抽),而不是「这道题没有标签」。
限时以 CF 提交时间判定。到达限时后,先成功同步该账号的提交再判负;网络失败或限时内提交仍在判题时继续等待,不把同步延迟算进用时。超时提交不生成挑战计时成绩。离线超过一天后仍优先结算已开始题目的及时 AC。
每日一题的实际抽题使用与预览相同的难度带。创建轮次和抽题在同一事务中完成,抽不到题会回滚,可直接换档位重试。
升级至本版本需要 schema v20;已有数据库请先运行 npm run db:init。