diff --git a/README.md b/README.md
index 92aec22..85e06e0 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
- **多平台记录**:集中查看 AC 记录、题目与逐次练习历史。
- **练习数据**:年度热力图、今日时间线、提交趋势和题目分布。
- **题友圈**:关注题友,查看他们在不同平台的做题动态。
+- **随机抽题与段位認定**:按档位抽题(初级/中级/上级/超上级)或**自定义条件**(难度范围 + 标签),只看得到难度;点「开始做题」才揭题并起计时。也可以连做 4 道走一轮挑战,或者走不分段的**小随机**/**大随机**段位。**每日一题按你的水平定难度带**(等效 Rating ±200)。
- **本地优先**:数据保存在自己的电脑;零第三方运行依赖,无需构建。
## 功能预览
@@ -103,6 +104,7 @@ DX Rating 是本项目的娱乐向练习指标,不是 Codeforces 官方 Rating
- [平台接入与数据边界](docs/platforms.md)
- [DX Rating 与 B50 计分说明](docs/dx-rating.md)
- [练习历史与离线回测](docs/backtesting.md)
+- [随机抽题与段位認定](docs/dan.md)
- [目录、数据模型与 API](docs/development.md)
- [Codeforces × maimai 统计实验](docs/cf-maimai-study.md)
diff --git a/docs/dan.md b/docs/dan.md
new file mode 100644
index 0000000..1b62d23
--- /dev/null
+++ b/docs/dan.md
@@ -0,0 +1,237 @@
+# 随机抽题与段位認定
+
+「随机抽题」把选题的权力从用户手里拿走:服务端抽一道,一开始只告诉你**难度**,点「开始做题」才揭题并起计时。照舞萌 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](dx-rating.md))。再往上题目数量也少得没法按 rating 均匀抽。
+
+## 抽题口径
+
+### 按 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。
+
+### 排除做过的题
+
+抽题前会剔除三类题:
+
+1. **本机提交记录里出现过的** —— 包括**只有 WA、没有 AC** 的(拿到 WA 也算见过,只排 AC 会让「做过但没做出来」的题反复出现)。
+2. **归档账号上的提交** —— 这里**故意不看 `accounts.is_archived`**,和其他地方「只算当前账号」的口径不一样(B50、题友圈、练习记录都带 `is_archived=0`)。归档的含义是「这个账号不再同步了」(换 handle、换号时旧账号会被归档),**不是**「那些题我没做过」;带上这个条件的话,换过一次 handle 的人之前 AC 过的题会全部重新变成可抽。
+3. **正在计时 / 计时过的题** —— 计时可以不经提交直接对题号开始(「专注计时」的正常用法),不看这一处的话段位有可能抽到你手上正在做的那道。
+
+依据是本机数据库,所以**同步不完整时仍可能有漏网的**(比如刚 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`),不是统一值。
+- 自定义只用于**单题**模式:挑战(段位認定)的档位是固定口径,不跟自定义条件混。
+
+## 口径 A:开始做题之前,链接不下发
+
+**未开始的题目,题号与链接根本不会离开服务端。** 这不是「发到前端再藏起来」,而是服务端在组装响应时就不给:
+
+- `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](dx-rating.md) 页面看到。
+
+## 自测,不是防作弊
+
+开始做题之后,你照样可以看题解、问 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` 在结束时冻结**整轮结果,记录页读的是快照,不是重新算一遍。
+
+## API
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| 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**:带标签条件抽题时把它当「没有标签信息」处理(一律不抽),而不是「这道题没有标签」。
\ No newline at end of file
diff --git a/docs/development.md b/docs/development.md
index 811ddb4..0ee4a95 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -80,6 +80,8 @@ data/backups/ npm run backup 生成的快照目录
- `problem_times`(v5):用户手填的**每题完成用时**,`(user_id, platform, problem_id)` 唯一。与 `submissions.execution_time`(平台给的判题毫秒数)完全是两回事,所以单独一张表,不混用。只存原始秒数,不存换算后的 rating —— 曲线一改(`npm run study:export-dx`)所有读数都得跟着变,落库就会多出一个真相来源。
- `contests`(v6):`(platform, contest_id)` 主键,存比赛名与**开始时间**,也就是题目的「出题日期」。b35/b15 的分板看它,不看 AC 时间。数据来自 CF 的 `contest.list?gym=false`,同步时按 6 小时 TTL upsert 写入;`problem_id` 的前缀(`339:A` → 339)就是比赛 id。不按 `type` 过滤 —— `contest.list` 的 `type='ICPC'` 并不是「ICPC 赛制」,Div.3 与 Educational 全是它。v8 加了 `duration_seconds`(比赛时长):判定「比赛内提交」靠它 —— 首页的最快用时只认比赛窗口内的 AC,时长缺失的比赛一律不判,宁可不算也不猜。
+- `dan_sessions` / `dan_stages`(v18,v19/v20 各加了一列):随机抽题与段位認定。一轮认定一行(档位、`kind` ∈ `challenge`/`single`/`daily`、题数、单题限时、rating 区间、状态),每道题一行(**抽题时快照下来的难度与限时**、`claimed_at`、计时器 id、结果)。难度快照进 `dan_stages` 而不是结算时回查题库,否则题库缓存过期或 CF 修订题目 rating 会让历史记录跟着变;`settlement_json` 同样在结束时冻结。v19 给 `dan_stages` 加了 `limit_seconds`:两个随机段位(`small_random` / `big_random`)rating 不分段、抽到的题横跨 800–2600,限时得随题走,NULL 表示沿用 session 的档位统一限时。v20 给 `dan_sessions` 加了 `tags_json`:自定义单题抽题(`tier='custom'`)的标签条件,NULL 表示无条件;难度范围复用本来就有的 `min_rating` / `max_rating`。随机段位沿用 `kind='challenge'`、自定义沿用 `kind='single'`,都靠 `tier` 区分 —— 加 `kind` 值要重建整张表(SQLite 改不了 `CHECK`)。一个用户同时只能有一轮 `active`,由 partial unique index `one_active_dan_per_user` 兜底。题号与链接在 `claimed_at` 之前不写入响应,详见 [随机抽题与段位認定](dan.md)。
+
时间统一存 UTC Unix 秒;执行时间为毫秒,内存为字节;缺失值为 NULL。删除用户或账号会级联删除相关提交,未来 UI 必须明确告知这一行为。
Schema 以 `PRAGMA user_version` 标记,当前版本由 `src/db/database.ts` 的迁移列表决定。初始化可重复运行;升级通过增量迁移保留记录。`openDatabase()` 在版本高于代码支持时拒绝打开、低于时按顺序补齐;只读面板则要求版本完全一致,并提示所需命令。
@@ -107,6 +109,12 @@ Schema 以 `PRAGMA user_version` 标记,当前版本由 `src/db/database.ts`
| `GET /api/sync/status` | 后台同步任务的当前进度与结果 |
| `GET /api/dx` | DX Rating 榜:曲线元信息、旧题 35 格 + 本年度新题 15 格(按**出题日期**分)、待填写清单(含未评定的题,`problemRating` 为 null)、缺出题日期的题数 |
| `POST /api/dx/time` · `POST /api/dx/time/clear` | 填写 / 清除某道题的完成用时(只接受已 AC 的 Codeforces 题) |
+| `GET /api/dan` | 抽题面板与记录页:档位表、题库缓存是否就绪、每日一题的**难度**、进行中的一轮、历史记录。只读,冷缓存时返回 `poolReady: false` |
+| `POST /api/dan/start` | 开一轮随机抽题 / 挑战 / 每日一题;已有一轮没结束时回 **409 `SESSION_ACTIVE`** |
+| `POST /api/dan/claim` | 起计时并**唯一一次**下发题号与链接;幂等,重复点击返回同一道题。未 claim 的题只含难度,见 [dan.md](dan.md#口径-a开始做题之前链接不下发) |
+| `POST /api/dan/settle` | **只结算、不抽题**。轮询走这条,所以出了成绩会停在结算页上;抽下一道要用户点「抽选下一题」走 `next` |
+| `POST /api/dan/next` | 结算已同步到的 AC 并抽下一道;区间里没题了回 `poolEmpty` |
+| `POST /api/dan/abandon` | 放弃进行中的一轮,释放进行中的计时器 |
| `POST /api/accounts` | 绑定账号;可选带凭据,直接写进 `.env`,值不回显 |
| `POST /api/accounts/unbind` | 解绑账号,必须带显式确认;回报连带删掉的提交条数 |
| `POST /api/accounts/replace` | 换绑同一用户的新账号,要求 `confirm: true`,旧账号归档、新账号从空记录开始 |
diff --git a/public/circle.html b/public/circle.html
index e3ae623..479cc26 100644
--- a/public/circle.html
+++ b/public/circle.html
@@ -12,6 +12,7 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
diff --git a/public/dan.css b/public/dan.css
new file mode 100644
index 0000000..fd3d0bc
--- /dev/null
+++ b/public/dan.css
@@ -0,0 +1,302 @@
+/*
+ * 随机抽题 / 每日一题 / 段位認定。
+ *
+ * 只写这一页特有的布局:按钮、输入、顶栏、卡片底色一律复用 styles.css / ui.css 的
+ * 既有类与变量,避免又开一套视觉语言(这是 public/ 下每个页面一致的做法)。
+ */
+
+.dan-app { display: grid; gap: 18px; max-width: 1080px; }
+
+.dan-hero {
+ background: linear-gradient(140deg, var(--accent-soft), transparent 70%), var(--panel);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 22px 24px; display: grid; gap: 8px;
+}
+.dan-hero h2 { margin: 0; font-size: 1.35rem; }
+.dan-hero p { margin: 0; color: var(--text-dim); font-size: .88rem; line-height: 1.75; max-width: 74ch; }
+.dan-eyebrow { font-family: var(--mono); font-size: .7rem; letter-spacing: .18em; text-transform: uppercase; color: var(--accent); }
+
+.dan-msg { margin: 0; min-height: 1.1rem; font-size: .85rem; color: var(--muted); }
+.dan-msg.error { color: var(--bad); }
+
+.dan-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+.dan-wide { grid-column: 1 / -1; }
+
+.dan-card {
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 18px; display: grid; gap: 12px; align-content: start;
+}
+.dan-card > header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
+.dan-card h3 { margin: 0; font-size: 1rem; }
+.dan-chip {
+ font-size: .72rem; color: var(--muted); border: 1px solid var(--border);
+ border-radius: 999px; padding: 2px 9px; white-space: nowrap;
+}
+.dan-note { margin: 0; color: var(--text-dim); font-size: .85rem; line-height: 1.7; }
+
+.dan-difficulty {
+ font-family: var(--mono); font-size: 2.1rem; font-weight: 600; color: var(--accent);
+ line-height: 1.1; display: grid; gap: 2px;
+}
+.dan-difficulty small { font-family: var(--sans); font-size: .7rem; font-weight: 400; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
+
+.dan-tiers { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
+.dan-tier {
+ display: grid; gap: 3px; text-align: left; font: inherit; cursor: pointer;
+ background: var(--bg-soft); border: 1px solid var(--border); color: var(--text);
+ border-radius: var(--radius-sm); padding: 11px 13px;
+ transition: border-color .12s, background .12s;
+}
+.dan-tier:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); }
+.dan-tier:disabled { opacity: .55; cursor: progress; }
+.dan-tier strong { font-size: .95rem; }
+.dan-tier small { color: var(--muted); font-size: .75rem; }
+/* 抽题分布是这两个模式唯一的区别,得在按钮上就说清楚,不能只写在正文里。 */
+.dan-tier-warn { color: var(--warn) !important; }
+.dan-note-sub { border-top: 1px dashed var(--border-soft); padding-top: 10px; }
+
+/* ---------- 自定义抽题(难度范围 + 标签) ---------- */
+.dan-custom { display: grid; gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border-soft); }
+.dan-custom-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
+.dan-custom-head h4 { margin: 0; font-size: .85rem; }
+.dan-custom-range { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.dan-custom-range label { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); }
+.dan-custom-range input {
+ width: 5.2em; font: inherit; font-size: .85rem; text-align: center; color: var(--text);
+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 5px 6px;
+}
+.dan-custom-range input:focus { outline: none; border-color: var(--accent); }
+.dan-custom-range .dan-note { margin: 0; }
+/* 标签云:题库有几十个标签,限高滚动,别把卡片撑成一整屏。 */
+.dan-tagcloud {
+ display: flex; flex-wrap: wrap; gap: 6px; max-height: 138px; overflow-y: auto;
+ border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 8px;
+}
+.dan-tag {
+ font: inherit; font-size: .72rem; cursor: pointer; color: var(--muted);
+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px;
+ transition: border-color .12s, color .12s, background .12s;
+}
+.dan-tag:hover { border-color: var(--accent); color: var(--text); }
+.dan-tag.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
+.dan-tag small { margin-left: 4px; opacity: .65; }
+.dan-tagcount { font-size: .72rem; color: var(--muted); }
+
+.dan-active {
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 20px; display: grid; gap: 16px;
+}
+.dan-active-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
+.dan-active-head h2 { margin: 0; font-size: 1.1rem; }
+.dan-badge { font-size: .72rem; border-radius: 999px; padding: 2px 9px; border: 1px solid transparent; white-space: nowrap; }
+.dan-badge.ok { color: var(--ac); background: var(--ac-soft); border-color: rgba(63, 185, 80, .32); }
+.dan-badge.bad { color: var(--bad); background: var(--bad-soft); border-color: rgba(248, 81, 73, .32); }
+.dan-badge.idle { color: var(--warn); background: var(--warn-soft); border-color: rgba(210, 153, 34, .32); }
+
+.dan-progress { display: flex; gap: 6px; }
+.dan-pip { flex: 1; height: 6px; border-radius: 999px; background: var(--border); }
+.dan-pip.now { background: var(--accent); }
+.dan-pip.done { background: var(--ac); }
+.dan-pip.fail { background: var(--bad); }
+
+.dan-stage {
+ display: grid; gap: 10px; padding: 15px; background: var(--bg-soft);
+ border: 1px solid var(--border-soft); border-radius: var(--radius-sm);
+}
+.dan-stage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
+.dan-clock { font-family: var(--mono); font-size: 1.15rem; color: var(--text); }
+.dan-clock.urgent { color: var(--warn); }
+.dan-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+
+.dan-history { display: grid; gap: 12px; }
+.dan-history-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.dan-history-head h2 { margin: 0; font-size: 1rem; }
+.dan-run {
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
+ padding: 13px 15px; display: grid; gap: 9px;
+}
+.dan-run-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
+.dan-total { font-family: var(--mono); font-size: 1.05rem; color: var(--accent); }
+.dan-run-list { display: grid; gap: 4px; font-size: .84rem; color: var(--text-dim); }
+.dan-run-row { display: grid; grid-template-columns: 2.4rem 1fr auto; gap: 10px; align-items: baseline; }
+.dan-run-row:last-child { padding-bottom: 0; }
+.dan-run-row a { color: var(--accent); text-decoration: none; font-family: var(--mono); }
+.dan-run-row a:hover { text-decoration: underline; }
+
+.dan-disclaimer {
+ margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.8;
+ border-top: 1px solid var(--border-soft); padding-top: 14px;
+}
+.dan-disclaimer strong { color: var(--text-dim); }
+
+/* ---------- 单题结算:底部那条段位進度带(结算皮复用 dx-result.css) ---------- */
+.dan-sheet-strip {
+ display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; align-items: baseline;
+ border-top: 1px solid var(--border-soft); padding-top: 10px; margin-top: 2px;
+ color: var(--text-dim); font-size: .8rem;
+}
+.dx-result-time .dx-result-limit { color: var(--muted); font-size: .62rem; }
+.dx-result-time { flex-direction: column; gap: 1px; }
+
+/* ---------- 段位認定结算页 ----------
+ * 版式照原作段位認定结算(Process/Course/CourseResultProcess):
+ * 奶油色纸盘 + 两侧水引红绳 + 每道一个「可」印 + 竖排逐曲卡
+ * (封面 / TRACK 0n / 难度 / 曲名 / 达成率 / LV / DX分数)
+ * + 底部「十段」框 · 绿心 · 总达成率 · DX分数 + 「好」按钮。
+ * 逐个对得上素材包 resources.assets 里的 UI_DNM_*:那枚「可」印是 UI_DNM_Stamp 的
+ * Clear / NoClear,两侧绳子是 UI_DNM_Separator,底部那块是 UI_DNM_DaniData。
+ * 贴图没有从素材包里搬:全部用 CSS 重画,避免把游戏美术带进这个 MIT 仓库。
+ * 盘面用大圆角而不是正圆 —— 三道题卡竖排进正圆,在常见宽度下会挤到不可读。
+ */
+/* dialog 默认是 shrink-to-fit,只写 max-width 撑不宽,这里必须给 width。 */
+.dan-result { border: 0; padding: 0; background: transparent; width: min(1040px, 96vw); max-width: none; color: #2b1c12; }
+.dan-result::backdrop { background: #05070ce0; }
+.dan-result-sheet {
+ position: relative; overflow: auto; max-height: 90vh;
+ background: radial-gradient(120% 80% at 50% -10%, #fffaf0, #f7e7cd 55%, #eed7b4), #f7e7cd;
+ border: 9px solid #d8461f; border-radius: 46px;
+ box-shadow: inset 0 0 0 3px #f7b23f, inset 0 0 0 7px #d8461f, inset 0 0 60px #e8b98155, 0 22px 60px #000b;
+ padding: 20px 46px 22px; display: grid; gap: 11px; justify-items: center;
+}
+.dan-result[data-verdict="failed"] .dan-result-sheet { border-color: #8c2b1a; }
+.dan-result[data-verdict="abandoned"] .dan-result-sheet { border-color: #8a8578; }
+.dan-result-close { position: absolute; top: 12px; right: 18px; padding: 0; font-size: 1.5rem; line-height: 1; color: #a8623a; background: none; border: 0; cursor: pointer; z-index: 2; }
+.dan-result-close:hover { color: #d8461f; }
+.dan-result-title { margin: 2px 0 0; font-size: 1.85rem; font-weight: 900; letter-spacing: .22em; color: #33200f; text-shadow: 0 1px 0 #fff; }
+.dan-result-sub { margin: 0 0 2px; font-size: .8rem; color: #9a7550; text-align: center; }
+
+/* 两侧水引(UI_DNM_Separator):上端蝴蝶结 + 竖绳 + 下端流苏 */
+.dan-cord { position: absolute; top: 16px; bottom: 16px; width: 30px; pointer-events: none; }
+.dan-cord.left { left: 14px; }
+.dan-cord.right { right: 14px; }
+.dan-cord::before {
+ content: ''; position: absolute; left: 50%; top: 34px; bottom: 36px; width: 5px; transform: translateX(-50%);
+ background: linear-gradient(#ff9d7a, #e0402a 40%, #c9381f); border-radius: 3px;
+}
+.dan-cord::after {
+ content: ''; position: absolute; left: 50%; top: 8px; width: 22px; height: 22px;
+ transform: translateX(-50%) rotate(45deg); background: #f2603c; border-radius: 4px 13px 6px 13px;
+ box-shadow: inset 0 0 0 2px #ffd2bd, 0 2px 5px #0004;
+}
+.dan-cord i {
+ position: absolute; left: 50%; bottom: 6px; width: 20px; height: 30px; transform: translateX(-50%);
+ background: repeating-linear-gradient(90deg, #e0402a 0 2px, transparent 2px 5px);
+}
+
+/* 逐曲卡:左边「可」印(UI_DNM_Stamp),右边一条难度配色的横卡 */
+.dan-result-tracks { display: grid; gap: 7px; width: 100%; margin: 0; padding: 0; list-style: none; }
+.dan-track { display: grid; grid-template-columns: 46px minmax(0, 1fr); gap: 9px; align-items: center; }
+.dan-track-stamp {
+ box-sizing: border-box; width: 46px; height: 50px; display: grid; place-items: center;
+ font-size: 1.15rem; font-weight: 900; color: #e0402a; background: #fff8ec;
+ border: 3px solid #e0402a; border-radius: 2px; box-shadow: 0 2px 6px #0002;
+ clip-path: polygon(29% 0, 71% 0, 100% 29%, 100% 71%, 71% 100%, 29% 100%, 0 71%, 0 29%);
+}
+.dan-track[data-outcome="timeout"] .dan-track-stamp,
+.dan-track[data-outcome="interrupted"] .dan-track-stamp { color: #8f8f8f; border-color: #9d9d9d; background: #f4f1ec; }
+.dan-track[data-outcome="pending"] .dan-track-stamp { color: #b9b1a6; border-color: #bcb4a8; background: #f4f1ec; }
+.dan-track-card {
+ display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 10px; align-items: center;
+ padding: 6px 12px 6px 6px; border-radius: 9px; border: 2px solid #ffffff59;
+ background: linear-gradient(100deg, var(--tc1, #8b6cf0), var(--tc2, #4a2fb0));
+ box-shadow: 0 3px 10px #0005, inset 0 1px #ffffff40;
+}
+.dan-track-jacket {
+ width: 96px; height: 68px; border-radius: 5px; display: grid; place-items: center;
+ border: 2px solid #ffffff66; box-shadow: inset 0 0 22px #00000066;
+ background: linear-gradient(140deg, #ffffff2e, #00000059);
+}
+.dan-track-jacket b { font-family: var(--mono); font-size: 1.5rem; font-weight: 900; line-height: 1; color: #fff; text-shadow: 0 2px 5px #000b; }
+.dan-track-jacket small { font-family: var(--mono); font-size: .58rem; color: #ffffffcc; }
+.dan-track-info { display: grid; gap: 3px; min-width: 0; }
+.dan-track-head { display: flex; gap: 7px; align-items: baseline; flex-wrap: nowrap; min-width: 0; }
+.dan-track-no { font-family: var(--mono); font-size: .66rem; font-weight: 700; letter-spacing: .1em; color: #ffe0b0; white-space: nowrap; }
+.dan-track-diff { font-family: var(--mono); font-size: .64rem; color: #fff; background: #0000004d; border-radius: 3px; padding: 1px 6px; white-space: nowrap; }
+.dan-track-name { flex: 1; min-width: 0; font-size: .76rem; color: #ffffffeb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dan-track-ach { display: flex; align-items: baseline; gap: 8px; }
+.dan-track-achlabel { font-size: .68rem; color: #ffffffd0; }
+.dan-track-achvalue { font-family: var(--mono); font-size: 1.8rem; font-weight: 900; letter-spacing: -.02em; }
+.dan-track-judge { font-size: .62rem; color: #3a2418; background: #ffd76a; border-radius: 999px; padding: 1px 9px; white-space: nowrap; }
+.dan-track[data-outcome="timeout"] .dan-track-judge,
+.dan-track[data-outcome="interrupted"] .dan-track-judge,
+.dan-track[data-outcome="pending"] .dan-track-judge { background: #d9d2c8; }
+.dan-track-side { display: grid; gap: 4px; justify-items: end; }
+.dan-track-lv {
+ font-family: var(--mono); font-size: .95rem; font-weight: 900; color: #fff; padding: 0 8px;
+ border: 2px solid #ffffff8c; border-radius: 6px; background: linear-gradient(#7b5cff, #4628b8);
+}
+.dan-track-score { display: flex; align-items: baseline; gap: 5px; }
+.dan-track-score small { font-size: .58rem; color: #ffffffb8; }
+.dan-track-score b { font-family: var(--mono); font-size: .9rem; color: #fff; }
+.dan-track-limit { font-size: .56rem; color: #ffffff9e; }
+
+/* 金色描边数字:白→金→橙渐变填充 + 深棕描边(原作那套 DX 数字,用 CSS 重画)。
+ * 必须分两层,试过两条捷径都不行:
+ * - 八向 text-shadow:偏移副本会把渐变填充整个盖住,只剩一圈棕边;
+ * - -webkit-text-stroke + paint-order: stroke fill:Chromium 的 paint-order 只对
+ * SVG 生效,HTML 文字上描边照样盖在填充之上,6px 描边直接吃掉整个笔画。
+ * 所以描边交给 ::before、渐变填充交给 ::after,两层叠在同一段文字(data-text)上。
+ * 这两点是抠图像素数数出来的,DOM 层面完全看不出来。 */
+.dan-gold { position: relative; display: inline-block; color: transparent; }
+.dan-gold::before, .dan-gold::after {
+ content: attr(data-text); position: absolute; left: 0; top: 0; white-space: nowrap; pointer-events: none;
+}
+.dan-gold::before { color: #5a2a10; -webkit-text-stroke: 6px #5a2a10; }
+.dan-gold::after {
+ background: linear-gradient(#fffce8, #ffd85c 42%, #ff9a1f 68%, #ffd85c);
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+}
+.dan-gold { filter: drop-shadow(0 4px 7px #00000059); }
+
+/* 底部:判定框 · 心 · 总达成率 / DX分数 */
+.dan-result-bottomrow { display: grid; grid-template-columns: minmax(140px, 210px) auto minmax(0, 1fr); gap: 16px; align-items: center; width: 100%; margin-top: 3px; }
+.dan-result-rankbox {
+ display: grid; place-items: center; gap: 1px; padding: 9px 12px; background: #fffdf6;
+ border: 3px solid #e6d3b4; border-radius: 13px; box-shadow: 0 3px 10px #00000030, inset 0 1px #fff;
+}
+.dan-result-rankbox strong { font-size: 2.35rem; font-weight: 900; letter-spacing: .05em; line-height: 1.1; color: #2b1c12; }
+.dan-result[data-verdict="failed"] .dan-result-rankbox strong { color: #b32a15; }
+.dan-result[data-verdict="abandoned"] .dan-result-rankbox strong { color: #7d8794; }
+.dan-result-rankbox small { font-size: .68rem; color: #9a7550; }
+.dan-result-life { position: relative; width: 88px; height: 88px; display: grid; place-items: center; }
+.dan-result-life::before {
+ content: '♥'; position: absolute; inset: 0; display: grid; place-items: center;
+ font-size: 92px; line-height: 1; color: #35c257;
+ -webkit-text-stroke: 5px #e6fbec; text-shadow: 0 4px 10px #0000004d;
+}
+.dan-result-life[data-state="bad"]::before { color: #9aa3ae; }
+.dan-result-life span { position: relative; z-index: 1; font-family: var(--mono); font-size: 1.2rem; font-weight: 900; color: #fff; text-shadow: 0 1px 4px #000000b3; }
+.dan-result-life span small { font-size: .62rem; opacity: .9; }
+.dan-result-totalblock { display: grid; gap: 2px; min-width: 0; }
+.dan-total-label { font-size: .74rem; font-weight: 700; letter-spacing: .1em; color: #8a5a2a; }
+.dan-total-ach { font-family: var(--mono); font-size: 2.5rem; font-weight: 900; letter-spacing: -.02em; }
+.dan-total-score { display: inline-flex; align-items: baseline; gap: 7px; justify-self: start; margin-top: 2px; padding: 2px 12px; border-radius: 6px; background: #fffdf699; border: 2px solid #e6d3b4; }
+.dan-total-score small { font-size: .64rem; color: #9a7550; }
+.dan-total-score b { font-family: var(--mono); font-size: 1rem; color: #2b1c12; }
+
+.dan-result-note { margin: 2px 0 0; max-width: 72ch; text-align: center; font-size: .78rem; line-height: 1.7; color: #96714b; }
+.dan-result-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; align-items: center; width: 100%; }
+.dan-result-ok {
+ min-width: 112px; padding: 7px 26px; font-size: 1.25rem; font-weight: 900; color: #fff; cursor: pointer;
+ background: linear-gradient(#ff9a4d, #e8542a); border: 3px solid #c9381f; border-radius: 11px;
+ box-shadow: 0 4px 0 #a32c15, 0 6px 14px #0000004d;
+}
+.dan-result-ok:active { transform: translateY(2px); box-shadow: 0 2px 0 #a32c15; }
+.dan-result-again { padding: 6px 16px; font-weight: 700; color: #7a4a20; background: #fffdf6; border: 2px solid #e6d3b4; border-radius: 9px; cursor: pointer; }
+
+@media (max-width: 860px) {
+ .dan-grid { grid-template-columns: 1fr; }
+ .dan-result-sheet { padding: 16px 22px 18px; border-radius: 30px; }
+ .dan-result-title { font-size: 1.4rem; }
+ .dan-cord { display: none; }
+ .dan-track-card { grid-template-columns: 62px minmax(0, 1fr); row-gap: 6px; }
+ .dan-track-jacket { width: 62px; height: 56px; }
+ .dan-track-jacket b { font-size: 1.15rem; }
+ .dan-track-side { grid-column: 2; grid-auto-flow: column; align-items: baseline; gap: 8px; justify-items: start; }
+ .dan-track-achvalue { font-size: 1.35rem; }
+ .dan-track-name { flex-basis: 8ch; }
+ .dan-result-bottomrow { grid-template-columns: 1fr; justify-items: center; }
+ .dan-total-ach { font-size: 2rem; }
+ .dan-total-score { justify-self: center; }
+ .dan-result-actions { justify-content: center; }
+}
\ No newline at end of file
diff --git a/public/dan.html b/public/dan.html
new file mode 100644
index 0000000..5cc330c
--- /dev/null
+++ b/public/dan.html
@@ -0,0 +1,78 @@
+
+
+
随机抽题 · Algorithm DX
+
+
+
+
+
+ DRAW, DON'T PICK
+ 抽一道,别挑。
+ 题目由服务端抽、结果只存在服务端。在你点「开始做题」之前,这道题的题号与链接根本不在这个页面里 —— 刷新、右键、F12 都提前看不到,因为它还不存在。你只看得到难度。点下去的那一刻同时起计时并跳转 Codeforces。
+
+
+
+
+
+
+
+ 按你的水平定难度带,同一天怎么刷新都是同一道。
+
+ 开始今日一题
+
+
+
+ 按 rating 均匀抽取,不是按题目均匀 —— 否则「初级」会几乎全是 800 分。抽完只显示难度。
+
+
+
自定义条件 难度范围 + 标签
+
+ 难度
+
+ –
+
+
+ 800–3500 · 2600 以上是外推段
+
+
+
不限标签 按条件抽一道
+
+
+
+
+ 做完一道立刻抽下一道,四道全部在限时内通关才算通过。这是挑战模式 ,不发段位名 —— 发段位名的是固定选曲段,随机段位認定本来就不给。
+
+
+
+
+ rating 不分段 :四个难度档的区间在这里合成一整段,抽到哪一档都算数。也是连续 4 道,也限时,超时同样整轮失败。
+
+ 两个的区别只有一件事:小随机 按 rating 均匀 —— 每个难度值被抽到的机会一样,难度铺得平;大随机 按题目均匀 —— 不保证难度分布,800 分那一桶因为本身题最多会成片出现,连着四道都是 800 是正常结果。
+
+
+
+
+
+
+
+口径:这是自测 ,不是防作弊。开始做题之后你照样可以查题解、问 AI —— 这一点拦不住,和「辅助解题」自报标签是同一条现实。它唯一能保证的是你没法挑题 。 另外,抽到的题如果正好是你在 Codeforces 上已经提交过的(含只有 WA 的)、或者在你已归档账号 上做过的、或者是你正在计时 的那道,都会被自动排除。排除依据是本机已同步的记录,同步不完整时(比如刚 AC 还没同步)可能仍有漏网。
+
+
+
\ No newline at end of file
diff --git a/public/dan.js b/public/dan.js
new file mode 100644
index 0000000..989ee06
--- /dev/null
+++ b/public/dan.js
@@ -0,0 +1,838 @@
+import { startAutoSync } from './auto-sync.js';
+import { cfRatingColor } from './cf-rating-colors.js';
+
+/*
+ * 随机抽题 / 每日一题 / 段位認定 的页面逻辑。
+ *
+ * 这一页刻意**不缓存、不预取**任何题目信息:服务端在下发时就不给未开始的题号与链接
+ * (见 src/dx/dan.ts 的口径 A),所以这里也没有任何「提前藏起来」的东西可以做 ——
+ * 唯一的入口是 beginStage(),它在 claim 成功之后才拿到 url 并立刻跳转。
+ * 自定义抽题(难度范围 + 标签)走同一条链:条件在 start 时发给服务端,
+ * 题目照样只在服务端抽,页面在这之前拿不到它是哪一道。
+ *
+ * 结算是两层的,和街机原作一致:
+ * - 每道题做完 → 单题结算(复用 public/dx-result.css 那套,与普通计时结算同一张皮);
+ * - 四道打完 → 段位認定结算页(`合格`/`不合格` 印章 + 逐题卡 + 合计)。
+ * 轮询只调 /api/dan/settle(只结算不抽题),所以出了成绩会停在结算上;
+ * 下一道由用户在单题结算上点「抽选下一题」才抽(/api/dan/next)。
+ */
+
+const $ = (id) => document.getElementById(id);
+const KIND_LABEL = { challenge: '段位認定', single: '随机抽题', daily: '每日一题' };
+const STATUS_LABEL = { active: '进行中', cleared: '通过', failed: '未通过', abandoned: '已放弃' };
+const OUTCOME_LABEL = { cleared: '通关', timeout: '超时', interrupted: '中断' };
+const STATUS_CLASS = { active: 'idle', cleared: 'ok', failed: 'bad', abandoned: 'bad' };
+/** 结算页的印章文字:对齐原作的「合格 / 不合格」。 */
+const VERDICT_TEXT = { cleared: '合格', failed: '不合格', abandoned: '中断' };
+/** 页面加载时,结束在这个秒数以内的一轮会把结算补弹一次(只补一次,不反复弹旧成绩)。 */
+const FRESH_RUN_SECONDS = 90;
+
+const tz = -new Date().getTimezoneOffset();
+let userId = null;
+let board = null;
+let clockOffset = 0;
+let pollTimer = null;
+let tickTimer = null;
+let lastActiveId = null;
+let busy = false;
+/** 已经弹过结算的那几道题(`sessionId:index`),避免轮询反复弹窗。 */
+let shownStages = null;
+/** 待展示的单题结算;有值就弹。 */
+let pendingSheet = null;
+
+const el = (tag, cls, text) => {
+ const node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+const field = (root, selector) => root.querySelector(selector);
+
+function setMessage(text, isError = false) {
+ $('message').textContent = text || '';
+ $('message').classList.toggle('error', Boolean(isError));
+}
+
+async function request(path, options = {}) {
+ const res = await fetch(path, { cache: 'no-store', signal: AbortSignal.timeout(20000), ...options });
+ let body = null;
+ try { body = await res.json(); } catch { body = null; }
+ if (!res.ok) throw new Error(body?.error || `请求失败(HTTP ${res.status})`);
+ return body ?? {};
+}
+const get = (path) => request(path);
+const post = (path, body) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+
+const serverNow = () => Date.now() / 1000 + clockOffset;
+const fmtClock = (seconds) => {
+ const total = Math.max(0, Math.floor(seconds));
+ return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
+};
+const fmtLimit = (seconds) => `${Math.round(seconds / 60)} 分钟`;
+const fmtDuration = (seconds) => (seconds == null ? '—'
+ : seconds >= 60 ? `${Math.floor(seconds / 60)} 分 ${String(seconds % 60).padStart(2, '0')} 秒` : `${seconds} 秒`);
+const fmtHMS = (seconds) => [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60]
+ .map((n) => String(n).padStart(2, '0')).join(':');
+const signed = (value, digits = 1) => `${value >= 0 ? '+' : ''}${value.toFixed(digits)}`;
+
+function difficultyNode(value, caption) {
+ const wrap = el('div', 'dan-difficulty');
+ wrap.append(document.createTextNode(String(value)), el('small', '', caption));
+ return wrap;
+}
+
+/** DX Rating 的分段数字(与 dx-timer.js 同一套 class,样式来自 dx-result.css)。 */
+function ratingDigits(text) {
+ const host = el('strong');
+ host.setAttribute('aria-label', text);
+ for (const character of text.padStart(Math.max(6, text.length), ' ')) {
+ const node = el('span', character === '.' ? 'dx-rating-point' : 'dx-rating-digit');
+ node.dataset.empty = String(character === ' ');
+ node.setAttribute('aria-hidden', 'true');
+ if (character === ' ') node.textContent = '\u00a0'; else node.textContent = character;
+ host.append(node);
+ }
+ return host;
+}
+
+async function boot() {
+ const meta = await get('/api/meta');
+ const users = meta.users ?? [];
+ // 抽题/认定必须绑在本人身上:计时器要拿本人绑定的 CF 账号去核对 AC。
+ const self = users.find((user) => user.is_self) ?? users[0];
+ if (!self) {
+ setMessage('还没有用户。请先在「账号管理」里创建「我」并绑定 Codeforces 账号。', true);
+ return;
+ }
+ userId = self.id;
+ await refresh();
+}
+
+async function refresh() {
+ const data = await get(`/api/dan?user=${userId}&tz=${tz}`);
+ board = data;
+ clockOffset = data.serverNow - Date.now() / 1000;
+ render();
+}
+
+function render() {
+ if (!board) return;
+ detectFinished();
+ renderIdle();
+ renderActive();
+ renderHistory();
+ if (!board.poolReady) setMessage('本地还没有题库缓存。点任意一个档位会自动抓一次全量题目(约 1.5 MB,之后 6 小时内复用)。');
+ schedule();
+ drainSheet();
+}
+
+/**
+ * 找出「刚刚结算」的那道题。
+ * 第一次渲染只建立基线,不弹窗 —— 否则中途刷新页面会把旧成绩全部弹一遍。
+ */
+/** 已结束的一轮要弹哪一屏:有成绩就弹最后一道的单题结算,按钮上给「查看本轮结算」。 */
+function sheetFor(run, finished) {
+ const last = run.stages.filter((stage) => stage.outcome).at(-1) ?? null;
+ return last ? { session: run, stage: last, finished, run } : { session: run, stage: null, finished, run };
+}
+
+function detectFinished() {
+ const session = board.active;
+ const settled = new Set();
+ if (session) {
+ for (const stage of session.stages) if (stage.outcome) settled.add(`${session.id}:${stage.index}`);
+ // 记下来:下一帧 active 消失时就靠它判断「刚刚结束的是哪一轮」。
+ lastActiveId = session.id;
+ }
+
+ if (shownStages === null) {
+ shownStages = settled;
+ // 页面加载时这一轮其实已经打完了(服务端的同步任务常常比刷新更快把 AC 结算掉):
+ // 把结果补给你,但只补刚结束不久的那一轮,避免每次打开页面都把旧成绩弹一遍。
+ if (!session) {
+ const run = (board.history ?? []).find((row) => row.finishedAt !== null && serverNow() - row.finishedAt < FRESH_RUN_SECONDS);
+ if (run) {
+ for (const stage of run.stages) shownStages.add(`${run.id}:${stage.index}`);
+ pendingSheet = sheetFor(run, true);
+ }
+ }
+ return;
+ }
+
+ if (session) {
+ const fresh = session.stages.filter((stage) => stage.outcome && !shownStages.has(`${session.id}:${stage.index}`));
+ shownStages = settled;
+ if (fresh.length) pendingSheet = { session, stage: fresh[fresh.length - 1], finished: false };
+ return;
+ }
+
+ // 进行中的那一轮消失了:刚打完(或刚被放弃)。
+ if (lastActiveId) {
+ const finished = (board.history ?? []).find((run) => run.id === lastActiveId);
+ lastActiveId = null;
+ if (finished) {
+ const key = finished.stages.map((stage) => `${finished.id}:${stage.index}`).filter((k) => !shownStages.has(k));
+ for (const stage of finished.stages) shownStages.add(`${finished.id}:${stage.index}`);
+ setMessage(`本轮已结束:${STATUS_LABEL[finished.status] ?? finished.status}${finished.totalRating === null ? '' : ` · 总分 ${finished.totalRating.toFixed(1)}`}`, finished.status !== 'cleared');
+ // 最后一道的单题结算先弹,按钮上给「查看本轮结算」;没有可弹的就直接弹整轮。
+ pendingSheet = key.length ? sheetFor(finished, true) : { session: finished, stage: null, finished: true, run: finished };
+ }
+ }
+}
+
+function tierButton(tier, kind) {
+ const button = el('button', 'dan-tier');
+ button.type = 'button';
+ button.append(el('strong', '', tier.name));
+ // 随机段位的限时随抽到的难度走,报不出一个确定的数字,就照实说。
+ const timing = tier.perStageLimit
+ ? '限时按当题难度'
+ : kind === 'challenge' ? `每道限时 ${fmtLimit(tier.limitSeconds)}` : `限时 ${fmtLimit(tier.limitSeconds)}`;
+ button.append(el('small', '', `${tier.minRating}–${tier.maxRating} · ${timing}`));
+ if (tier.draw === 'problem') button.append(el('small', 'dan-tier-warn', '按题目均匀 · 不保证难度分布'));
+ else if (tier.perStageLimit) button.append(el('small', 'dan-tier-warn', '按 rating 均匀 · 每个难度等概率'));
+ button.disabled = busy;
+ button.addEventListener('click', () => startRun(kind, tier.key));
+ return button;
+}
+
+function renderIdle() {
+ const session = board.active;
+ $('idle').hidden = Boolean(session);
+ if (session) return;
+
+ $('dailyDate').textContent = board.dateKey ?? '';
+ // 每日一题的难度带按水平定:把带子说出去,用户才知道「今天的难度为什么是这个」。
+ const bandNote = $('dailyBandNote');
+ const band = board.dailyBand;
+ if (band?.center !== null && band?.center !== undefined) {
+ bandNote.textContent = `按你的水平定带:等效 Rating ${Math.round(band.equivalentRating)} → ${band.minRating}–${band.maxRating}。同一天怎么刷新都是同一道。`;
+ } else {
+ bandNote.textContent = '还没有可以定带的成绩(B50 里没有带用时的 AC),先从 800–2600 全段抽。';
+ }
+ const dailyHost = $('dailyDifficulty');
+ dailyHost.replaceChildren();
+ if (board.daily) dailyHost.append(difficultyNode(board.daily.difficulty, '今日难度 · CF 官方 Rating'));
+ else dailyHost.append(el('p', 'dan-note', board.poolReady ? '这个难度带里今天已经没有你没做过的题了。' : '题库缓存未就绪。'));
+ $('dailyStart').disabled = busy || !board.daily;
+
+ const singles = $('singleTiers');
+ singles.replaceChildren(...(board.tiers ?? []).map((tier) => tierButton(tier, 'single')));
+ const challenges = $('challengeTiers');
+ challenges.replaceChildren(...(board.tiers ?? []).map((tier) => tierButton(tier, 'challenge')));
+ const randoms = $('randomTiers');
+ randoms.replaceChildren(...(board.randomTiers ?? []).map((tier) => tierButton(tier, 'challenge')));
+ renderTagCloud();
+}
+
+/** 选中的标签存在这里(Set),点标签切换;空集 = 不限标签。 */
+const pickedTags = new Set();
+
+/** 计数器要说清两件事:选了几个、多个标签之间是「含任一」而不是「全含」。 */
+function syncTagCount() {
+ $('tagCount').textContent = pickedTags.size ? `已选 ${pickedTags.size} 个 · 含任一` : '不限标签';
+}
+
+function renderTagCloud() {
+ const host = $('tagCloud');
+ host.replaceChildren();
+ const tags = board.tags ?? [];
+ const known = new Set(tags.map((tag) => tag.name));
+ // 缓存刷新后个别标签可能消失:留在选择集里只会让服务端拒一次,先清掉。
+ for (const name of pickedTags) if (!known.has(name)) pickedTags.delete(name);
+ if (!tags.length) {
+ host.append(el('span', 'dan-note', board.poolReady ? '题库没有标签数据(缓存是老版本,点一次抽题后会自动刷新)。' : '题库缓存未就绪。'));
+ } else {
+ for (const tag of tags) {
+ const chip = el('button', 'dan-tag');
+ chip.type = 'button';
+ chip.append(document.createTextNode(tag.name), el('small', '', String(tag.count)));
+ chip.classList.toggle('on', pickedTags.has(tag.name));
+ chip.addEventListener('click', () => {
+ if (pickedTags.has(tag.name)) pickedTags.delete(tag.name); else pickedTags.add(tag.name);
+ chip.classList.toggle('on', pickedTags.has(tag.name));
+ syncTagCount();
+ });
+ host.append(chip);
+ }
+ }
+ syncTagCount();
+}
+
+function progressBar(session) {
+ const bar = el('div', 'dan-progress');
+ for (let index = 1; index <= session.stageCount; index += 1) {
+ const stage = session.stages.find((row) => row.index === index);
+ const cls = !stage ? '' : stage.outcome === 'cleared' ? 'done' : stage.outcome ? 'fail' : 'now';
+ bar.append(el('span', `dan-pip ${cls}`.trim()));
+ }
+ return bar;
+}
+
+function stageCard(session, stage) {
+ const card = el('div', 'dan-stage');
+ const head = el('div', 'dan-stage-head');
+ head.append(el('strong', '', `第 ${stage.index} / ${session.stageCount} 道`));
+ if (stage.claimed && stage.deadlineAt) {
+ const clock = el('span', 'dan-clock');
+ clock.dataset.deadline = String(stage.deadlineAt);
+ head.append(clock);
+ } else {
+ head.append(el('span', 'dan-chip', '还没开始'));
+ }
+ card.append(head);
+ card.append(difficultyNode(stage.difficulty, '题目难度 · CF 官方 Rating'));
+
+ const actions = el('div', 'dan-actions');
+ if (!stage.claimed) {
+ card.append(el('p', 'dan-note', '这道题的题号与链接现在不在这个页面的任何地方 —— 点下去才开始揭题并起计时。'));
+ const start = el('button', 'btn btn-primary', '开始做题');
+ start.type = 'button';
+ start.disabled = busy;
+ start.addEventListener('click', () => beginStage(session));
+ actions.append(start);
+ } else {
+ const go = el('a', 'btn btn-primary', '回到 Codeforces');
+ go.href = stage.problemUrl ?? '#';
+ go.rel = 'noreferrer noopener';
+ actions.append(go);
+ const done = el('button', 'btn btn-ghost', '立即检查 AC');
+ done.type = 'button';
+ done.disabled = busy;
+ done.addEventListener('click', () => settleNow());
+ actions.append(done);
+ if (stage.problemId) actions.append(el('span', 'dan-chip', stage.problemId));
+ }
+ card.append(actions);
+ return card;
+}
+
+function renderActive() {
+ const host = $('active');
+ host.replaceChildren();
+ const session = board.active;
+ host.hidden = !session;
+ if (!session) return;
+
+ const head = el('div', 'dan-active-head');
+ head.append(el('h2', '', `${session.tierName} · ${KIND_LABEL[session.kind] ?? session.kind}`));
+ head.append(el('span', `dan-badge ${STATUS_CLASS[session.status] ?? 'idle'}`, STATUS_LABEL[session.status] ?? session.status));
+ // 逐题限时的模式报不出确定的数,就照实说(与档位按钮同一句话)。
+ head.append(el('span', 'dan-chip', session.perStageLimit ? '限时按当题难度' : `限时 ${fmtLimit(session.limitSeconds)} / 道`));
+ head.append(el('span', 'dan-chip', `${session.minRating}–${session.maxRating}`));
+ if (session.tags?.length) head.append(el('span', 'dan-chip', `标签:${session.tags.join('、')}`));
+ host.append(head);
+ host.append(progressBar(session));
+
+ const current = session.stages.find((stage) => !stage.outcome);
+ if (current) host.append(stageCard(session, current));
+ else if (session.stages.some((stage) => stage.outcome)) {
+ // 出了成绩就停在这儿,等用户点了「抽选下一题」再抽。
+ const last = session.stages.filter((stage) => stage.outcome).at(-1);
+ host.append(el('p', 'dan-note', `第 ${last.index} 道已结算,正在等你看结算 —— 点结算页上的按钮才会抽下一道。`));
+ }
+ if (session.stages.some((stage) => stage.outcome)) {
+ const review = el('button', 'btn btn-ghost', '再看一次上一道结算');
+ review.type = 'button';
+ review.addEventListener('click', () => { pendingSheet = { session, stage: session.stages.filter((s) => s.outcome).at(-1), finished: false }; drainSheet(); });
+ const actions0 = el('div', 'dan-actions');
+ actions0.append(review);
+ host.append(actions0);
+ }
+
+ const settled = session.stages.filter((row) => row.outcome);
+ if (settled.length) {
+ const list = el('div', 'dan-run-list');
+ for (const stage of settled) list.append(settledLine(stage));
+ host.append(list);
+ }
+
+ const actions = el('div', 'dan-actions');
+ const abandon = el('button', 'btn btn-ghost', '放弃这一轮');
+ abandon.type = 'button';
+ abandon.disabled = busy;
+ abandon.addEventListener('click', () => abandonRun(session.id));
+ actions.append(abandon);
+ host.append(actions);
+}
+
+function settledLine(stage) {
+ const row = el('div', 'dan-run-row');
+ row.append(el('span', '', `#${stage.index}`));
+ const middle = el('span');
+ middle.append(document.createTextNode(`${stage.difficulty} 分`));
+ if (stage.rank) middle.append(document.createTextNode(` · ${stage.rank} ${stage.achievement?.toFixed(2) ?? ''}%`));
+ if (stage.rating !== null) middle.append(document.createTextNode(` · ${stage.rating.toFixed(1)} 分`));
+ middle.append(document.createTextNode(` · ${OUTCOME_LABEL[stage.outcome] ?? stage.outcome}`));
+ row.append(middle);
+ row.append(el('span', '', stage.problemId ?? '—'));
+ return row;
+}
+
+function renderHistory() {
+ const host = $('history');
+ host.replaceChildren();
+ const runs = board.history ?? [];
+ if (!runs.length) {
+ host.append(el('p', 'dan-note', '还没有记录。上面抽一道,做完就会留在这里。'));
+ return;
+ }
+ for (const run of runs) host.append(runCard(run));
+}
+
+function runCard(run) {
+ const card = el('article', 'dan-run');
+ const head = el('div', 'dan-run-head');
+ head.append(el('strong', '', `${run.tierName} · ${KIND_LABEL[run.kind] ?? run.kind}`));
+ head.append(el('span', `dan-badge ${STATUS_CLASS[run.status] ?? 'idle'}`, STATUS_LABEL[run.status] ?? run.status));
+ head.append(el('span', 'dan-chip', new Date(run.startedAt * 1000).toLocaleString('zh-CN', { hour12: false })));
+ if (run.tags?.length) head.append(el('span', 'dan-chip', `标签:${run.tags.join('、')}`));
+ if (run.totalRating !== null) head.append(el('span', 'dan-total', `总分 ${run.totalRating.toFixed(1)}`));
+ card.append(head);
+
+ const list = el('div', 'dan-run-list');
+ for (const stage of run.stages) {
+ const row = el('div', 'dan-run-row');
+ row.append(el('span', '', `#${stage.index}`));
+ const middle = el('span');
+ middle.append(document.createTextNode(`${stage.difficulty} 分`));
+ if (stage.rank) middle.append(document.createTextNode(` · ${stage.rank}`));
+ if (stage.achievement !== null) middle.append(document.createTextNode(` · ${stage.achievement.toFixed(2)}%`));
+ if (stage.seconds !== null) middle.append(document.createTextNode(` · ${fmtDuration(stage.seconds)}`));
+ if (stage.outcome) middle.append(document.createTextNode(` · ${OUTCOME_LABEL[stage.outcome] ?? stage.outcome}`));
+ row.append(middle);
+ if (stage.problemUrl) {
+ const link = el('a', '', stage.problemId);
+ link.href = stage.problemUrl;
+ link.rel = 'noreferrer noopener';
+ row.append(link);
+ } else {
+ row.append(el('span', '', '—'));
+ }
+ list.append(row);
+ }
+ card.append(list);
+
+ const actions = el('div', 'dan-actions');
+ const open = el('button', 'btn btn-ghost', '查看结算');
+ open.type = 'button';
+ open.addEventListener('click', () => openRunResult(run));
+ actions.append(open);
+ card.append(actions);
+ return card;
+}
+
+/* ---------------- 单题结算(与普通计时结算同一张皮) ---------------- */
+
+let stageDialog = null;
+function buildStageDialog() {
+ const dialog = el('dialog', 'dx-result');
+ dialog.setAttribute('aria-labelledby', 'danResultTitle');
+ dialog.innerHTML = `
+
×
+
STAGE
+
+
+
+
+
+
+
`;
+ document.body.append(dialog);
+ field(dialog, '.dx-result-close').addEventListener('click', () => dialog.close());
+ return dialog;
+}
+
+function showStageSheet(pending) {
+ const { session, stage, finished } = pending;
+ if (!stage) { openRunResult(pending.run ?? session); return; }
+ stageDialog ??= buildStageDialog();
+ const dialog = stageDialog;
+ const score = stage.comparison?.currentScore ?? null;
+ const comparison = stage.comparison ?? null;
+
+ field(dialog, '.dx-result-kicker b').textContent = String(stage.index).padStart(2, '0');
+ field(dialog, '.dx-result-kicker span').textContent = `${session.tierName} · ${session.stageCount} 道中的第 ${stage.index} 道`;
+ field(dialog, '#danResultTitle').textContent = stage.outcome === 'cleared' ? 'CLEAR!' : stage.outcome === 'timeout' ? 'TIME UP' : 'INTERRUPTED';
+ field(dialog, '.dx-result-kind').textContent = KIND_LABEL[session.kind] ?? session.kind;
+ field(dialog, '.dx-result-problem').textContent = stage.problemId ?? '';
+ field(dialog, '.dx-result-name').textContent = stage.title ?? stage.problemId ?? '';
+ field(dialog, '.dx-result-level strong').textContent = String(stage.difficulty);
+
+ const achievement = field(dialog, '.dx-result-achievement-value');
+ achievement.textContent = stage.achievement === null ? (stage.outcome === 'cleared' ? '已通关' : '没有成绩') : `${stage.achievement.toFixed(4)}%`;
+ const previous = comparison?.previousScore ?? null;
+ const delta = field(dialog, '.dx-result-achievement-delta');
+ if (previous && score) {
+ const improvement = score.achievementShown - previous.achievementShown;
+ delta.textContent = `${signed(improvement, 4)}%`;
+ delta.dataset.direction = improvement < 0 ? 'down' : 'up';
+ } else {
+ delta.textContent = previous ? '暂无对比' : '首次通过';
+ delta.dataset.direction = 'up';
+ }
+ field(dialog, '.dx-result-best b').textContent = previous ? `${previous.achievementShown.toFixed(4)}%` : '—';
+ field(dialog, '.dx-result-record').hidden = !(previous && score && score.achievementShown > previous.achievementShown);
+
+ field(dialog, '.dx-result-rank').textContent = stage.rank ?? (stage.outcome === 'timeout' ? '—' : '');
+ field(dialog, '.dx-result-time strong').textContent = stage.seconds === null ? '—' : fmtHMS(stage.seconds);
+ field(dialog, '.dx-result-limit').textContent = `限时 ${Math.round(stage.limitSeconds / 60)}:00`;
+ field(dialog, '.dx-result-rating strong').textContent = String(stage.difficulty);
+ field(dialog, '.dx-result-rating').lastElementChild.textContent = previous && score ? signed(score.rating - previous.rating, 1) : '';
+
+ // AC / CS 勋章
+ const verdicts = stage.verdicts ?? {};
+ const cleanSolve = (verdicts.AC ?? 0) > 0 && Object.entries(verdicts).every(([verdict, count]) => verdict === 'AC' || count === 0);
+ field(dialog, '.dx-result-award-cs').hidden = !cleanSolve;
+ const verdictHost = field(dialog, '.dx-result-verdicts');
+ verdictHost.replaceChildren();
+ for (const verdict of ['AC', 'WA', 'TLE', 'RE', 'CE', 'MLE', 'OTHER']) {
+ const count = verdicts[verdict] ?? 0;
+ if (!count && !['AC', 'WA'].includes(verdict)) continue;
+ const tag = el('div', `dx-result-verdict dx-result-verdict-${verdict.toLowerCase()}`);
+ tag.append(el('span', '', verdict), el('strong', '', String(count)));
+ verdictHost.append(tag);
+ }
+
+ const totalHost = field(dialog, '.dx-result-total-score');
+ totalHost.replaceChildren();
+ const totalFrame = field(dialog, '.dx-result-total');
+ if (comparison) {
+ const totalText = Number(comparison.ratingAfter).toFixed(1);
+ totalHost.append(ratingDigits(totalText));
+ field(dialog, '.dx-result-total-delta').textContent = signed(Number(comparison.ratingDelta), 1);
+ const color = cfRatingColor(Number(comparison.ratingAfter));
+ totalFrame.dataset.ratingTone = color.tone;
+ totalFrame.title = `DX Rating · ${color.label}(${color.range})`;
+ } else {
+ totalHost.append(ratingDigits('—'));
+ field(dialog, '.dx-result-total-delta').textContent = '—';
+ delete totalFrame.dataset.ratingTone;
+ totalFrame.title = '本次没有生成 B50 对比(超时或中断不计分)';
+ }
+
+ const done = session.stages.filter((row) => row.outcome);
+ const accumulated = done.reduce((sum, row) => sum + (row.rating ?? 0), 0);
+ const strip = field(dialog, '.dan-sheet-strip');
+ strip.replaceChildren(
+ el('span', '', `本轮 ${done.length} / ${session.stageCount} 完成`),
+ el('span', '', `已累计 ${accumulated.toFixed(1)} 分`),
+ el('span', '', finished || done.length >= session.stageCount ? '本轮已打完'
+ // 随机段位下一道还没抽出来,限时是多少现在还不知道 —— 不编一个数字。
+ : session.perStageLimit ? '下一道限时按当题难度'
+ : `下一道仍是${session.tierName},限时 ${fmtLimit(session.limitSeconds)}`),
+ );
+
+ const next = field(dialog, '.dx-result-next');
+ const isLast = finished || done.length >= session.stageCount;
+ next.textContent = isLast ? '查看本轮结算 ›' : '抽选下一题 ›';
+ next.onclick = () => { dialog.close(); if (isLast) openRunResult(pending.run ?? session); else settleThenDraw(); };
+
+ if (!dialog.open) dialog.showModal();
+}
+
+/** 轮询/按钮触发的「只结算」:不抽题。 */
+async function settleNow() {
+ if (busy) return;
+ busy = true;
+ try {
+ const data = await post('/api/dan/settle', { userId, tz });
+ clockOffset = data.serverNow - Date.now() / 1000;
+ await refresh();
+ } catch (error) {
+ setMessage(error.message, true);
+ } finally {
+ busy = false;
+ render();
+ }
+}
+
+/** 「抽选下一题」:这一步才真的抽。 */
+async function settleThenDraw() {
+ if (busy) return;
+ busy = true;
+ setMessage('正在抽下一道…');
+ try {
+ const data = await post('/api/dan/next', { userId, tz });
+ clockOffset = data.serverNow - Date.now() / 1000;
+ if (data.poolEmpty) setMessage('这个档位已经没有你没做过的题了,换一个档位吧。', true);
+ else setMessage('');
+ await refresh();
+ } catch (error) {
+ setMessage(error.message, true);
+ } finally {
+ busy = false;
+ render();
+ }
+}
+
+function drainSheet() {
+ if (!pendingSheet) return;
+ if (stageDialog?.open) return;
+ const pending = pendingSheet;
+ pendingSheet = null;
+ showStageSheet(pending);
+}
+
+/* ---------------- 段位認定结算页(对齐原作 UI_DNM_Result_* 的构成) ---------------- */
+
+/** 难度色号 → 逐曲卡的渐变(与 CF 分色同一套边界;violet 是原作 MASTER 的紫)。 */
+const TRACK_TONE = {
+ red: ['#e8564f', '#a3211c'], orange: ['#f08c3a', '#a85a12'],
+ violet: ['#8b6cf0', '#4a2fb0'], blue: ['#4a7fe0', '#1e3f96'],
+ cyan: ['#3fa8c4', '#166b83'], green: ['#4aa85c', '#1d6b30'],
+ gray: ['#7b8391', '#3f4652'], unrated: ['#7b8391', '#3f4652'],
+};
+
+let resultDialog = null;
+function buildResultDialog() {
+ const dialog = el('dialog', 'dan-result');
+ dialog.setAttribute('aria-labelledby', 'danRunTitle');
+ dialog.innerHTML = `
+
×
+
+
+
段位認定
+
+
+
+
+
+
+ 总达成率
+
+ DX分数
+
+
+
+
+
+ 好
+
`;
+ document.body.append(dialog);
+ field(dialog, '.dan-result-close').addEventListener('click', () => dialog.close());
+ field(dialog, '.dan-result-ok').addEventListener('click', () => dialog.close());
+ return dialog;
+}
+
+/** `.dan-gold` 要两层(描边 ::before + 渐变填充 ::after),两层都读 `data-text`。 */
+function goldNode(cls, text) {
+ const node = el('strong', cls, text);
+ node.dataset.text = text;
+ return node;
+}
+
+function openRunResult(run) {
+ resultDialog ??= buildResultDialog();
+ const dialog = resultDialog;
+ const verdict = run.status === 'cleared' ? 'cleared' : run.status === 'failed' ? 'failed' : 'abandoned';
+ const cleared = run.stages.filter((stage) => stage.outcome === 'cleared');
+ const scored = cleared.filter((stage) => stage.achievement !== null);
+
+ dialog.dataset.verdict = verdict;
+ const limitText = run.perStageLimit ? '每道限时按当题难度' : `每道限时 ${fmtLimit(run.limitSeconds)}`;
+ field(dialog, '.dan-result-sub').textContent =
+ `${run.tierName} · ${run.minRating}–${run.maxRating} · 共 ${run.stageCount} 道 · ${limitText}`
+ + ` · ${run.draw === 'problem' ? '按题目均匀' : '按 rating 均匀'}`
+ + (run.tags?.length ? ` · 标签 ${run.tags.join('、')}` : '');
+
+ const tracks = field(dialog, '.dan-result-tracks');
+ tracks.replaceChildren();
+ for (const stage of run.stages) {
+ const tone = TRACK_TONE[cfRatingColor(stage.difficulty).tone] ?? TRACK_TONE.violet;
+ const item = el('li', 'dan-track');
+ item.dataset.outcome = stage.outcome ?? 'pending';
+ item.append(el('span', 'dan-track-stamp', stage.outcome === 'cleared' ? '可' : '不可'));
+
+ const card = el('div', 'dan-track-card');
+ card.style.setProperty('--tc1', tone[0]);
+ card.style.setProperty('--tc2', tone[1]);
+
+ // 「封面」那一格放题号:我们抽的是 CF 题、没有曲绘,放题号比放装饰诚实。
+ const [contest, index] = (stage.problemId ?? '').split(':');
+ const jacket = el('div', 'dan-track-jacket');
+ jacket.append(el('b', '', index || '?'), el('small', '', contest || '未开始'));
+ card.append(jacket);
+
+ const info = el('div', 'dan-track-info');
+ const head = el('div', 'dan-track-head');
+ head.append(el('span', 'dan-track-no', `STAGE ${String(stage.index).padStart(2, '0')}`));
+ head.append(el('span', 'dan-track-diff', `${stage.difficulty} 分`));
+ head.append(el('span', 'dan-track-name', stage.title ?? '—'));
+ info.append(head);
+
+ const achievement = el('div', 'dan-track-ach');
+ achievement.append(el('span', 'dan-track-achlabel', '达成率'));
+ achievement.append(goldNode('dan-track-achvalue dan-gold',
+ stage.achievement === null ? '—' : `${stage.achievement.toFixed(4)}%`));
+ achievement.append(el('span', 'dan-track-judge',
+ stage.outcome === 'cleared' ? (stage.rank ?? '通关')
+ : stage.outcome === 'timeout' ? '超时' : stage.outcome === 'interrupted' ? '中断' : '未开始'));
+ info.append(achievement);
+ card.append(info);
+
+ const side = el('div', 'dan-track-side');
+ side.append(el('span', 'dan-track-lv', String(stage.difficulty)));
+ const score = el('span', 'dan-track-score');
+ score.append(el('small', '', 'DX分数'), el('b', '', stage.rating === null ? '—' : stage.rating.toFixed(1)));
+ side.append(score);
+ side.append(el('small', 'dan-track-limit', `限时 ${fmtLimit(stage.limitSeconds)}`));
+ card.append(side);
+ item.append(card);
+ tracks.append(item);
+ }
+
+ field(dialog, '.dan-result-rankbox strong').textContent = VERDICT_TEXT[run.status] ?? '中断';
+ field(dialog, '.dan-result-rankbox small').textContent = `${cleared.length} / ${run.stageCount} 道通关`;
+
+ // 原作这一格是显示剩余生命的心;我们没有生命值,用「通关几道」占这一格。
+ const life = field(dialog, '.dan-result-life');
+ life.dataset.state = run.status === 'cleared' ? 'ok' : 'bad';
+ field(dialog, '.dan-result-life span').replaceChildren(
+ document.createTextNode(String(cleared.length)), el('small', '', `/${run.stageCount}`));
+
+ // 原作的「总达成率」是四首歌达成率之和、「DX分数」是四首 DX 分数之和;
+ // 这里对应各道达成率之和与各道单题 rating 之和。
+ const totalAchievement = scored.reduce((sum, stage) => sum + stage.achievement, 0);
+ const totalText = scored.length ? `${totalAchievement.toFixed(4)}%` : '—';
+ const totalNode = field(dialog, '.dan-total-ach');
+ totalNode.textContent = totalText;
+ totalNode.dataset.text = totalText;
+ field(dialog, '.dan-total-score b').textContent = run.totalRating === null ? '—' : run.totalRating.toFixed(1);
+
+ const deltas = run.stages.map((stage) => stage.comparison?.ratingDelta).filter((value) => typeof value === 'number');
+ let note = run.status === 'cleared'
+ ? `${run.stageCount} 道都在限时内解出。每道题的成绩已经作为单题记录存进练习记录,并计入 B50。`
+ : run.status === 'failed'
+ ? `有一道超出了限时,本轮就此结束。超时那道没有成绩(计时器被取消、不生成练习记录);已经通过的 ${cleared.length} 道照样计入 B50。`
+ : `这一轮被中断或放弃,没有生成总成绩。已经通过的 ${cleared.length} 道照样计入 B50。`;
+ // 大随机段位不保证难度分布,这是它的定义而不是抽题出了 bug —— 必须说清楚。
+ if (run.draw === 'problem') {
+ note += ' 这一轮是大随机段位:抽题按题目均匀,难度分布就是题库的真实分布,'
+ + '所以「连着好几道都是同一个难度」是正常结果。';
+ }
+ if (deltas.length) note += ` 本轮对 B50 总分的影响:${signed(Math.round(deltas.reduce((sum, value) => sum + value, 0) * 10) / 10)}。`;
+ field(dialog, '.dan-result-note').textContent = note;
+
+ const again = field(dialog, '.dan-result-again');
+ again.textContent = `再来一轮 · ${run.tierName}`;
+ again.onclick = () => {
+ dialog.close();
+ if (run.kind === 'challenge') startRun('challenge', run.tier);
+ // 自定义轮次的「再来」要带上当时的条件,否则变成全段乱抽。
+ else if (run.tier === 'custom') startRun('single', 'custom',
+ { minRating: run.minRating, maxRating: run.maxRating, tags: run.tags ?? [] });
+ else startRun('single', run.tier);
+ };
+
+ if (!dialog.open) dialog.showModal();
+}
+
+/* ---------------- 动作 ---------------- */
+
+async function startRun(kind, tier, extra = {}) {
+ if (busy) return;
+ busy = true;
+ setMessage(kind === 'daily' ? '正在抽今天的题…' : '正在抽题…');
+ render();
+ try {
+ await post('/api/dan/start', { userId, kind, tier, tz, ...extra });
+ await refresh();
+ setMessage('');
+ } catch (error) {
+ setMessage(error.message, true);
+ } finally {
+ busy = false;
+ render();
+ }
+}
+
+async function beginStage(session) {
+ if (busy) return;
+ busy = true;
+ setMessage('正在锁题并起计时…');
+ try {
+ const claimed = await post('/api/dan/claim', { userId, sessionId: session.id });
+ // 链接唯一的出口:这一跳之前,题目从没进过这个页面。
+ window.location.href = claimed.url;
+ } catch (error) {
+ setMessage(error.message, true);
+ busy = false;
+ await refresh();
+ }
+}
+
+async function abandonRun(sessionId) {
+ if (busy || !window.confirm('放弃这一轮?本轮会记为「已放弃」,不计入成绩。')) return;
+ busy = true;
+ try {
+ await post('/api/dan/abandon', { userId, sessionId });
+ await refresh();
+ setMessage('已放弃这一轮。');
+ } catch (error) {
+ setMessage(error.message, true);
+ } finally {
+ busy = false;
+ render();
+ }
+}
+
+/**
+ * 做了题就轮询。这里调的是 **settle 而不是 next**:只结算、不抽题,
+ * 所以出了成绩会停在结算页上,等用户自己点「抽选下一题」。没在计时就不轮询。
+ */
+function schedule() {
+ clearTimeout(pollTimer);
+ clearInterval(tickTimer);
+ const session = board?.active;
+ const current = session?.stages.find((stage) => !stage.outcome);
+ if (session && current?.claimed) {
+ pollTimer = setTimeout(async () => {
+ try {
+ const data = await post('/api/dan/settle', { userId, tz });
+ clockOffset = data.serverNow - Date.now() / 1000;
+ await refresh();
+ } catch {
+ schedule();
+ }
+ }, 5000);
+ }
+ tickTimer = setInterval(() => {
+ for (const node of document.querySelectorAll('.dan-clock[data-deadline]')) tick(node);
+ }, 1000);
+ for (const node of document.querySelectorAll('.dan-clock[data-deadline]')) tick(node);
+}
+
+function tick(node) {
+ const left = Number(node.dataset.deadline) - serverNow();
+ node.textContent = left > 0 ? `剩 ${fmtClock(left)}` : '已超时';
+ node.classList.toggle('urgent', left > 0 && left <= 60);
+}
+
+$('dailyStart').addEventListener('click', () => startRun('daily'));
+$('customStart').addEventListener('click', () => {
+ const min = Number($('customMin').value), max = Number($('customMax').value);
+ // 本地先拦一道明显的错(非数字、区间反了),省一次网络往返;真正的口径校验在服务端。
+ if (!Number.isFinite(min) || !Number.isFinite(max)) { setMessage('难度上下限要是数字。', true); return; }
+ if (min > max) { setMessage('难度下限不能大于上限。', true); return; }
+ startRun('single', 'custom', { minRating: min, maxRating: max, tags: [...pickedTags] });
+});
+$('refresh').addEventListener('click', () => refresh().catch((error) => setMessage(error.message, true)));
+
+boot()
+ .then(() => startAutoSync(() => refresh()))
+ .catch((error) => setMessage(error.message, true));
\ No newline at end of file
diff --git a/public/dx.html b/public/dx.html
index f451c9b..86b02f5 100644
--- a/public/dx.html
+++ b/public/dx.html
@@ -20,6 +20,7 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
diff --git a/public/help.html b/public/help.html
index 215260c..b22843c 100644
--- a/public/help.html
+++ b/public/help.html
@@ -11,13 +11,14 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
GETTING STARTED 使用指南 从绑定第一个账号,到记录练习、看懂成绩与统计。
前往账号管理 ↗
-首次使用 名称与账号 ID 通用 Cookie 教程 洛谷数据范围 同步与历史 CF 自建比赛 码蹄集同步 用时与 B50 常见问题 评分规则 模型与验证
+首次使用 名称与账号 ID 通用 Cookie 教程 洛谷数据范围 同步与历史 CF 自建比赛 码蹄集同步 用时与 B50 常见问题 评分规则 模型与验证 随机抽题
1. 需要注册吗? Algorithm DX 是本机应用,没有单独的注册、密码或云端账号。你需要在 Codeforces、码蹄集等平台上已有账号;如果没有,先在对应平台官网完成注册。
打开首页「账号管理」,在「这个账号属于谁」中选择「添加一个人」。 起一个本地显示名称,例如“我”,并勾选「标记为我」。同一个人的其他平台账号以后选这个名称,不要重复建人。 选择平台,按输入框下的提示填写用户名或数字 ID。需要登录 Cookie 的平台可按下面的通用 Cookie 教程填写。 点击「保存账号」,再点「同步最新数据」。保存不等于已经抓取了数据。 添加关注的人:为对方建一个本地名称,不勾选「我」,再把其公开平台账号绑定到这个名称。关注仅影响本机展示,不会在原平台发起关注或通知对方。码蹄集登录后可查询网站允许查看的其他用户做题记录。
2. 本地名称、昵称、账号 ID 有什么区别? 本地名称 由你起,方便把同一个人的多个平台账号放在一起。平台用户名 / ID 用来找到真正的账号,必须按个人主页地址填写。
Codeforces:/profile/ 后的用户名。 AtCoder:/users/ 后的用户名。 力扣国际站 / 中国站:个人主页 /u/ 后的字符,不一定与显示昵称相同。 洛谷:/user/ 后的数字。 码蹄集:自己的个人主页通常不显示 ID,填写 Cookie 后点击「识别我的账号」即可自动填入;他人账号需已知 ID 或含 /other-homepage/ 的链接。 不要填写密码、电子邮箱或完整主页网址来代替账号 ID。输入框下也会随平台显示对应例子。
@@ -114,6 +115,15 @@ 2. 取得 API key / secret 后配置
在 DX 页面「更多 → 本地回测 / 导出」可以查看当前与旧版完成度模型的对比,并下载汇总 JSON。回测会区分计时来源、练习类型和未完成次数;未知练习日期不进入训练过程分析,未满 B35/B15 不能代表充分训练。报告不自动调整评分参数,练习数据不会自动上传。
实现及研究记录保存在项目 docs/cf-maimai-study.md、docs/backtesting.md;界面中的参数和验证状态来自服务端当前模型。
+10. 随机抽题与段位認定
+不想挑题的时候,让服务端替你抽一道。打开导航里的「随机抽题」,选一个档位即可。四个档位按 Codeforces 官方难度划分,覆盖 800–2600:初级 800–1100(限时 30 分钟)、中级 1200–1500(40 分钟)、上级 1600–2000(50 分钟)、超上级 2100–2600(60 分钟)。
+「每日一题」的难度按你当前的水平定 :以你的等效 Rating(B50 榜的平均单题 rating × 50 —— 榜填满 50 格时就是 DX Rating 本身)为中心 ±200,而不是全段乱抽;还没有可计分的成绩(没 AC 过、或 AC 了没填过用时)时才退回 800–2600 全段。同一天怎么刷新都是同一道,限时 45 分钟。
+「随机抽题」还可以自定义条件 :难度范围(800–3500,2600 以上是评分外推段)+ 标签(多选,含任一 ,来自 CF 题库自带的标签,页面上按题量排序)。条件只支持 Codeforces —— 抽题要有全量题库,目前只有 CF 有。点「按条件抽一道」之前同样看不到题号与链接。
+点「开始做题」之前,这道题的题号和链接根本不在页面里 —— 服务端在组装响应时就不下发,所以刷新、右键、F12 都提前看不到,你只看得到难度。点下去的那一刻同时起计时并跳转到 Codeforces。抽题按难度均匀取(不是按题目均匀),并会跳过本机已同步到的提交记录里出现过的题,包括只有 WA 的。
+「段位認定」是挑战模式:做完一道立刻抽下一道,一共四道,每道各自限时。四道全部在限时内通关才算通过;任何一道超时即判本轮失败并结束。一轮只能进行一个,中途可以放弃。挑战模式不发段位名 ,结算只给各题结果与总分 —— 给段位名的是固定选曲段,随机段位認定本来就不给。
+「随机段位認定」分小随机 与大随机 两种,rating 都不分段(覆盖 800–2600 整段),都是四道、都限时。区别只有一件事 :小随机按 rating 均匀,每个难度值被抽到的机会一样;大随机按题目均匀,不保证 难度分布 —— 800 分那一桶因为本身题最多,出现得比别的难度频繁。随机段位的限时随抽到的难度走 (800 分给 30 分钟、2600 分给 60 分钟),不是一刀切。
+抽题与挑战的成绩走的是正常的计时练习那条链,所以照常计入 B50 与 DX Rating ,可以在 DX 页面看到。最后请记住:这是自测,不是防作弊。开始做题之后你照样可以查题解、问 AI,这一点拦不住 —— 它唯一能保证的是你没法挑题。
+
支持项目 · 感谢你的使用与反馈
diff --git a/public/index.html b/public/index.html
index b28b792..8445a4b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,7 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
diff --git a/public/stats.html b/public/stats.html
index 9934d1f..173686e 100644
--- a/public/stats.html
+++ b/public/stats.html
@@ -19,6 +19,7 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
diff --git a/src/db/database.ts b/src/db/database.ts
index beece01..f43d6d5 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -22,6 +22,9 @@ const MIGRATIONS: readonly { appliesWhenAtMost: number; file: string }[] = [
{ appliesWhenAtMost: 14, file: './migrations/015-timer-settlement.sql' },
{ appliesWhenAtMost: 15, file: './migrations/016-group-rating-sources.sql' },
{ appliesWhenAtMost: 16, file: './migrations/017-group-source-evidence.sql' },
+ { appliesWhenAtMost: 17, file: './migrations/018-dan.sql' },
+ { appliesWhenAtMost: 18, file: './migrations/019-dan-random.sql' },
+ { appliesWhenAtMost: 19, file: './migrations/020-dan-custom.sql' },
];
export const SCHEMA_VERSION = MIGRATIONS.length;
diff --git a/src/db/migrations/018-dan.sql b/src/db/migrations/018-dan.sql
new file mode 100644
index 0000000..ceec28c
--- /dev/null
+++ b/src/db/migrations/018-dan.sql
@@ -0,0 +1,51 @@
+-- v18:随机抽题、每日一题与段位認定(挑战模式)。
+--
+-- 为什么单独建表,不复用 practice_timers:
+-- 计时器只知道「一道题从开始到 AC」,它没有「这是第几道」和「这一轮要几道」的
+-- 概念。挑战/认定是一组题的**序列**,胜负要靠整轮判定,必须有自己的状态机。
+--
+-- 为什么 dan_stages 要存 difficulty 快照:
+-- CF 的题目评级会变(公布、回填、重评),而这一轮的进展与结算必须可复现 ——
+-- 与 practice_timers.settlement_json 是同一条理由(见 migrations/015)。
+--
+-- 为什么 session 行冗余存 stage_count / limit_seconds:
+-- 这两条规则随档位走(src/dx/dan.ts 的 DAN_TIERS)。存进 session 是为了让
+-- **历史记录永远按当时的规则解释** —— 以后调档位区间或限时,不会把旧记录
+-- 重新解释成另一个结果。
+CREATE TABLE IF NOT EXISTS dan_sessions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ tier TEXT NOT NULL CHECK(length(tier) > 0),
+ kind TEXT NOT NULL CHECK(kind IN ('challenge','single','daily')),
+ stage_count INTEGER NOT NULL CHECK(stage_count >= 1),
+ limit_seconds INTEGER NOT NULL CHECK(limit_seconds > 0),
+ min_rating INTEGER NOT NULL CHECK(min_rating > 0),
+ max_rating INTEGER NOT NULL CHECK(max_rating >= min_rating),
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','cleared','failed','abandoned')),
+ started_at INTEGER NOT NULL,
+ finished_at INTEGER,
+ total_rating REAL,
+ settlement_json TEXT
+);
+CREATE INDEX IF NOT EXISTS dan_session_user ON dan_sessions(user_id, started_at DESC);
+-- 一个用户同时只能有一轮进行中的认定,与 practice_timers 的
+-- one_running_timer_per_user 同一个理由:并行的两轮无法判定「第几道」。
+CREATE UNIQUE INDEX IF NOT EXISTS one_active_dan_per_user ON dan_sessions(user_id) WHERE status = 'active';
+
+CREATE TABLE IF NOT EXISTS dan_stages (
+ id INTEGER PRIMARY KEY,
+ session_id TEXT NOT NULL REFERENCES dan_sessions(id) ON DELETE CASCADE,
+ stage_index INTEGER NOT NULL CHECK(stage_index >= 1),
+ -- 抽到的题:只有服务端知道,任何读接口都不下发(见 src/dx/dan.ts 口径 A)。
+ problem_id TEXT NOT NULL CHECK(length(problem_id) > 0),
+ difficulty INTEGER NOT NULL CHECK(difficulty > 0),
+ drawn_at INTEGER NOT NULL,
+ claimed_at INTEGER,
+ timer_id TEXT REFERENCES practice_timers(id) ON DELETE SET NULL,
+ outcome TEXT CHECK(outcome IN ('cleared','timeout','interrupted')),
+ seconds INTEGER,
+ score_json TEXT,
+ UNIQUE(session_id, stage_index)
+);
+CREATE INDEX IF NOT EXISTS dan_stage_session ON dan_stages(session_id, stage_index);
+PRAGMA user_version = 18;
\ No newline at end of file
diff --git a/src/db/migrations/019-dan-random.sql b/src/db/migrations/019-dan-random.sql
new file mode 100644
index 0000000..e78c1da
--- /dev/null
+++ b/src/db/migrations/019-dan-random.sql
@@ -0,0 +1,17 @@
+-- v19:随机段位認定(小随机 / 大随机)。
+--
+-- 为什么需要「逐题限时」这一列:
+-- 四个难度档的区间互不重叠,一个档位配一个统一限时是合理的。但随机段位**不分段**,
+-- 抽到的题横跨 800-2600 —— 给 800 分的题 60 分钟、给 2600 分的题也 60 分钟,
+-- 两头都不公平。所以限时随题走,落在 stage 上。
+--
+-- 为什么 NULL 是合法的:
+-- 019 之前抽出来的行没有这个值,它们属于「档位统一限时」那一套规则。
+-- NULL = 沿用 session.limit_seconds,于是旧记录的解释一个字都不用改。
+--
+-- 没有重建 dan_sessions:随机段位沿用 kind='challenge',靠 tier 区分
+-- (small_random / big_random,见 src/dx/dan.ts 的 DAN_RANDOM_TIERS)。
+-- 加一个 kind 值要重建整张表(SQLite 改不了 CHECK),而 tier 已经能表达这件事。
+ALTER TABLE dan_stages ADD COLUMN limit_seconds INTEGER;
+
+PRAGMA user_version = 19;
\ No newline at end of file
diff --git a/src/db/migrations/020-dan-custom.sql b/src/db/migrations/020-dan-custom.sql
new file mode 100644
index 0000000..91983f3
--- /dev/null
+++ b/src/db/migrations/020-dan-custom.sql
@@ -0,0 +1,9 @@
+-- 自定义单题抽题:把用户选的标签存进轮次。
+--
+-- 只加一列。NULL = 这一轮没有标签条件(四个档位、两个随机段位与每日一题都是),
+-- 历史记录的解释不变。
+--
+-- 难度范围不用加列:dan_sessions 本来就有 min_rating / max_rating,
+-- 自定义抽题直接复用这两列,tier 记 'custom'。
+ALTER TABLE dan_sessions ADD COLUMN tags_json TEXT;
+PRAGMA user_version = 20;
diff --git a/src/domain.ts b/src/domain.ts
index cde74cf..8b4d88b 100644
--- a/src/domain.ts
+++ b/src/domain.ts
@@ -75,4 +75,10 @@ export interface ProblemRating {
contestId: number;
index: string;
rating: number;
+ /**
+ * 题目的官方标签(CF 的 `problemset.problems` 原样带回)。
+ * 老缓存(`cf:problemset-ratings:v1`)里没有这个字段 —— 读取方必须把它当
+ * 「没有标签信息」处理,而不是「这道题没有标签」。
+ */
+ tags?: readonly string[];
}
diff --git a/src/dx/dan.ts b/src/dx/dan.ts
new file mode 100644
index 0000000..e097c57
--- /dev/null
+++ b/src/dx/dan.ts
@@ -0,0 +1,812 @@
+import { createHash, randomInt as cryptoRandomInt } from 'node:crypto';
+import type { DatabaseSync } from 'node:sqlite';
+import type { ProblemRating } from '../domain.ts';
+import { scoreProblem } from './rating.ts';
+
+/**
+ * 随机抽题、每日一题与段位認定(挑战模式)—— **唯一实现**。
+ *
+ * 三件事共用同一套抽题引擎:
+ * 1. **随机题目**:选一个档位,抽一道题(`single`);也可以**自定义**难度范围与
+ * 标签(tier=`custom`,条件存 `dan_sessions` 的区间列与 `tags_json`)
+ * 2. **每日一题**:按(日期 + 用户)确定性抽一道,同一天刷新不变(`daily`);
+ * 难度带**按用户当前的等效 Rating 定**(±200,见 `dailyBand`)—— 不再全段乱抽
+ * 3. **段位認定**:同一档位连抽 4 道、逐题限时,全部通关才算通过(`challenge`)
+ * 四个难度档之外还有两个**随机段位**:小随机与大随机(`DAN_RANDOM_TIERS`)。
+ * 注意口径:这是**挑战模式**,不发段位名。maimai 的随机段位認定同样不发段位名,
+ * 发段位名的是固定选曲段 —— 所以这里也不造分数线,结算只出各题成绩与总分。
+ *
+ * ## 三条口径在这里定死
+ *
+ * **A. 题目链接在「开始做题」之前绝不下发。**
+ * 抽题发生在服务端,结果只落库;任何读接口只返回**难度分数**(和档位),
+ * 不返回 `problem_id`、题名、tags 或 URL。URL 只在 claim(锁题 + 起计时器)那一次
+ * 响应里出现。这不是「前端藏起来」,是「客户端里根本还没有」—— F12、右键、
+ * 网络面板都拿不到不存在的东西。
+ *
+ * 刻意**不做**右键屏蔽与 devtools 检测:那类拦截可被 View-Source、直接发请求、
+ * 换浏览器绕过,只会挡住正常用户并制造虚假的安全感。真正的边界是数据不下发。
+ *
+ * **B. 抽题默认按 rating 均匀,不按题目均匀 —— 但「大随机段位」刻意反过来。**
+ * 题库里 800-899 有 1104 道,而其他桶只有约 450 道。按题目均匀抽会让「初级」
+ * 几乎全是 800 分的题,所以档位抽题的做法是:先均匀选一个 rating 值,再在该 rating
+ * 内均匀选一道(`draw: 'rating'`)。
+ *
+ * 大随机段位(`draw: 'problem'`)**故意**改成在题目上均匀:它存在的意义就是
+ * 「不保证难度分布」,让简单题按它们在题库里的真实占比出现。这是 maimai 里
+ * 「随机段位」与「大随机段位」的区别 —— 前者难度铺得平,后者就是抓一把。
+ *
+ * **C. 难度在抽题时快照进 `dan_stages`。**
+ * CF 的题目评级会变(公布、回填、重评),而这一轮的进展与结算必须可复现 ——
+ * 与 `practice_timers.settlement_json` 是同一条理由。
+ *
+ * ## 与计时器的关系
+ *
+ * 本模块**只读** `practice_timers`,不 import `timer.ts`(依赖是单向的:
+ * `timer.ts` → `dan.ts`)。claim 时由调用方先用 `startTimer` 起计时器,
+ * 再把 `timer_id` 交进来;结算时直接读那张表的状态,与 `reconcileTimers`
+ * 的判定天然一致。
+ *
+ * 唯一的例外是**超时取消**:段位限时是 dan 这一层的规则(不是计时器规则),
+ * 所以在这里把该计时器置为 `cancelled`,语句与 `cancelTimer`(timer.ts)完全一致。
+ * 不能在这里调用 `cancelTimer` —— 它会回调 `reconcileTimers`,而 `reconcileTimers`
+ * 又会回调本函数,形成递归。
+ *
+ * ## 口径边界(必须显示给用户)
+ *
+ * 这是一套**自测**,不是防作弊。claim 之后你照样可以看题解、问 AI —— 这一点
+ * 拦不住,与 `assisted` 自报标签是同一条现实。它唯一能保证的是「**你没法挑题**」。
+ */
+
+/** 挑战模式一轮的题数。 */
+export const DAN_STAGES = 4;
+
+/**
+ * 一轮认定的总时长上限(秒),防止「抽了不点」把唯一的活动轮次永久占住
+ * (`one_active_dan_per_user` 是唯一索引)。24 小时足够走完,也足够在
+ * 关了浏览器之后回来接着做。
+ */
+export const DAN_SESSION_TTL_SECONDS = 86400;
+
+export interface DanTier {
+ key: string;
+ name: string;
+ minRating: number;
+ maxRating: number;
+ /** 单题限时(秒)。超时即该轮失败并结束。`perStageLimit` 为真时这只是展示回退值。 */
+ limitSeconds: number;
+ /**
+ * 抽题分布(口径 B):
+ * `rating` —— 先均匀选 rating 值,再在该值内均匀选一道,难度铺得平;
+ * `problem` —— 直接在题目上均匀,难度分布就是题库的真实分布。
+ */
+ draw: 'rating' | 'problem';
+ /** true = 逐题限时按**当题难度**定,不用 `limitSeconds`。随机段位专用。 */
+ perStageLimit?: boolean;
+}
+
+/**
+ * 四个档位。区间按 `problemset.problems` 的实际分布划定(各区间都有 1800+ 道题),
+ * 上界 2600 是刻意留的:T97 曲线在 2000 以上是**已启用的外推**
+ * (`productionReady: false`,见 results/cf-study/VALIDATION.md),
+ * 把「超上级」再往上抬会让分数越来越依赖外推段。
+ */
+export const DAN_TIERS: readonly DanTier[] = [
+ { key: 'beginner', name: '初级', minRating: 800, maxRating: 1100, limitSeconds: 1800, draw: 'rating' },
+ { key: 'intermediate', name: '中级', minRating: 1200, maxRating: 1500, limitSeconds: 2400, draw: 'rating' },
+ { key: 'advanced', name: '上级', minRating: 1600, maxRating: 2000, limitSeconds: 3000, draw: 'rating' },
+ { key: 'expert', name: '超上级', minRating: 2100, maxRating: 2600, limitSeconds: 3600, draw: 'rating' },
+];
+
+/**
+ * 两个**随机段位**:rating 区间取四个档位的并集(800–2600),**不分段**。
+ *
+ * - 小随机段位:按 rating 均匀 —— 每个 rating 值被抽到的机会一样,难度跨度铺得平。
+ * - 大随机段位:按**题目**均匀 —— 不保证难度分布,800 分那一桶因为本身题最多,
+ * 会成片地出现,「连着几道都是 800」是正常结果而不是 bug。
+ *
+ * 两者都设 `perStageLimit`:抽到的题横跨 800-2600,统一限时对两头都不公平。
+ */
+export const DAN_RANDOM_TIERS: readonly DanTier[] = [
+ { key: 'small_random', name: '小随机段位', minRating: 800, maxRating: 2600, limitSeconds: 3600,
+ draw: 'rating', perStageLimit: true },
+ { key: 'big_random', name: '大随机段位', minRating: 800, maxRating: 2600, limitSeconds: 3600,
+ draw: 'problem', perStageLimit: true },
+];
+
+/**
+ * **自定义单题抽题**:难度范围与标签由用户在请求里给(只支持 `kind='single'`),
+ * 区间不写死在这里 —— 真正的范围存 `dan_sessions.min_rating / max_rating`,
+ * 标签存 `dan_stages` 同表的 `tags_json`(迁移 020)。
+ *
+ * 上下界放宽到 3500(题库的真实上界):这是用户自己的明确选择,与档位刻意停在
+ * 2600 的那条「评分口径」约束不是一回事 —— 但 2600 以上仍然是 T97 外推段,
+ * 页面上要说明。`perStageLimit` 与随机段位同理:1200 的题给 60 分钟、3500 的题
+ * 给 30 分钟都说不过去,限时随当题难度走。
+ */
+export const DAN_CUSTOM_TIER: DanTier = {
+ key: 'custom', name: '自定义', minRating: 800, maxRating: 3500, limitSeconds: 3600,
+ draw: 'rating', perStageLimit: true,
+};
+
+/** 自定义抽题允许的难度范围与标签数(越界在 API 层拒绝,这里只放口径)。 */
+export const DAN_CUSTOM_MIN_RATING = 800;
+export const DAN_CUSTOM_MAX_RATING = 3500;
+export const DAN_CUSTOM_MAX_TAGS = 8;
+
+/** 每日一题:全档位并集,一天一道。不属于可选的档位。 */
+export const DAILY_TIER: DanTier = {
+ key: 'daily', name: '每日一题', minRating: 800, maxRating: 2600, limitSeconds: 2700, draw: 'rating',
+};
+
+export type DanKind = 'challenge' | 'single' | 'daily';
+
+export function danTier(key: string): DanTier | null {
+ if (key === DAILY_TIER.key) return DAILY_TIER;
+ if (key === DAN_CUSTOM_TIER.key) return DAN_CUSTOM_TIER;
+ return DAN_TIERS.find((tier) => tier.key === key)
+ ?? DAN_RANDOM_TIERS.find((tier) => tier.key === key) ?? null;
+}
+
+/** 可选档位(用于校验前端传来的 tier 参数,daily 不能当档位选)。 */
+export function selectableDanTier(key: string): DanTier | null {
+ return DAN_TIERS.find((tier) => tier.key === key)
+ ?? DAN_RANDOM_TIERS.find((tier) => tier.key === key) ?? null;
+}
+
+/**
+ * 按难度取限时。随机段位的题横跨 800–2600,统一限时对两头都不公平,所以随题走。
+ *
+ * 取「上界不低于它的第一个档位」而不是「落在区间内」:CF 评级是 100 的倍数,
+ * 实际不会落进 1100→1200 这类空档,但万一日后区间调整,这个写法仍然单调。
+ */
+export function danLimitForRating(rating: number): number {
+ return DAN_TIERS.find((tier) => rating <= tier.maxRating)?.limitSeconds
+ ?? DAN_TIERS[DAN_TIERS.length - 1].limitSeconds;
+}
+
+/** 这一道题的限时:随机段位按当题难度,其余档位用档位自己的统一限时。 */
+export function danStageLimit(tier: DanTier | null, rating: number, fallback: number): number {
+ return tier?.perStageLimit ? danLimitForRating(rating) : fallback;
+}
+
+/**
+ * 已落库的那一道的限时。
+ *
+ * `limit_seconds` 为 NULL 的是 019 之前抽出来的行 —— 它们属于「档位统一限时」
+ * 那一套规则,沿用 session 的值,旧记录的解释一个字都不用改。
+ */
+export function danStageLimitOf(
+ stage: { limit_seconds: number | null },
+ session: { limit_seconds: number },
+): number {
+ return stage.limit_seconds ?? session.limit_seconds;
+}
+
+export function danStageCount(kind: DanKind): number {
+ return kind === 'challenge' ? DAN_STAGES : 1;
+}
+
+/**
+ * 题号归一:**统一大写**。
+ *
+ * `codeforces.ts` 拼 `problem_id` 时不做大小写归一(直接用 API 返回的 index),
+ * 而 `normalizeTimerProblem`(timer.ts)会 `toUpperCase()`。为了「已做过的题」
+ * 排除集与 `startTimer` 都匹配得上,这里统一按大写产出,并在比对时也归一。
+ */
+export function danProblemId(problem: ProblemRating): string {
+ return `${problem.contestId}:${problem.index.toUpperCase()}`;
+}
+
+export function danProblemUrl(problemId: string): string {
+ const colon = problemId.indexOf(':');
+ const contest = Number(problemId.slice(0, colon));
+ const index = problemId.slice(colon + 1);
+ const kind = contest >= 100000 ? 'gym' : 'contest';
+ return `https://codeforces.com/${kind}/${contest}/problem/${encodeURIComponent(index)}`;
+}
+
+export function danDateKey(epochSeconds: number, tzOffsetMinutes: number): string {
+ return new Date((epochSeconds + tzOffsetMinutes * 60) * 1000).toISOString().slice(0, 10);
+}
+
+export interface DanPoolOptions {
+ minRating: number;
+ maxRating: number;
+ /** 已见过的题号(大写)。抽题时排除。 */
+ exclude: ReadonlySet;
+ /** 抽题分布,默认 `rating`(口径 B)。`problem` 只有「大随机段位」用。 */
+ draw?: 'rating' | 'problem';
+ /**
+ * 标签条件(自定义抽题):非空时只抽**至少带其中一个标签**的题(「含任一」)。
+ * 用「含任一」而不是「全含」—— 全含会把池子筛到只剩几道,「dp AND graphs AND
+ * greedy」这种交集在 11000 道的题库里也常常是空的。
+ */
+ tags?: ReadonlySet;
+}
+
+/** 注入随机源,便于单测;生产用 crypto(服务端不可预测,见口径 A)。 */
+export type DanRandomInt = (maxExclusive: number) => number;
+
+const defaultRandomInt: DanRandomInt = (maxExclusive) => cryptoRandomInt(maxExclusive);
+
+/**
+ * 把题库切成「按 rating 升序的桶」,并保证**桶内顺序稳定**。
+ *
+ * 稳定排序是每日一题可复现的前提:题库来自 CF 的 JSON 数组,顺序不保证,
+ * 若桶内顺序随响应变化,同一个种子会抽到不同的题。
+ */
+function groupDanCandidates(pool: readonly ProblemRating[], options: DanPoolOptions): ProblemRating[][] {
+ const byRating = new Map();
+ const wantTags = options.tags !== undefined && options.tags.size > 0;
+ for (const problem of pool) {
+ if (!Number.isSafeInteger(problem.rating)) continue;
+ if (problem.rating < options.minRating || problem.rating > options.maxRating) continue;
+ const id = danProblemId(problem);
+ if (options.exclude.has(id)) continue;
+ // 标签条件是「含任一」。老缓存(v1)里没有 tags 字段 —— 那是「没有标签信息」,
+ // 不是「这道题没有标签」,带标签条件时一律不抽,而不是把整池当无标签放行。
+ if (wantTags && !(problem.tags ?? []).some((tag) => options.tags!.has(tag))) continue;
+ const bucket = byRating.get(problem.rating);
+ if (bucket) bucket.push(problem); else byRating.set(problem.rating, [problem]);
+ }
+ return [...byRating.entries()]
+ .sort((a, b) => a[0] - b[0])
+ .map(([, bucket]) => bucket.sort((a, b) => danProblemId(a).localeCompare(danProblemId(b))));
+}
+
+/**
+ * 随机抽一道。
+ *
+ * 默认按 rating 均匀(口径 B):先均匀选 rating 值,再在该 rating 内均匀选一道。
+ * `draw: 'problem'` 时改成在题目上直接均匀 —— 难度分布就是题库的真实分布,
+ * 800 分那一桶因为本身题最多会成片出现。这是「大随机段位」的定义,不是缺陷。
+ */
+export function drawDanCandidate(
+ pool: readonly ProblemRating[],
+ options: DanPoolOptions,
+ randomInt: DanRandomInt = defaultRandomInt,
+): ProblemRating | null {
+ const buckets = groupDanCandidates(pool, options);
+ if (buckets.length === 0) return null;
+ if (options.draw === 'problem') {
+ // flat() 之前桶已按 rating 升序、桶内按题号升序排过,顺序稳定,单测可复现。
+ const problems = buckets.flat();
+ return problems[randomInt(problems.length)] ?? null;
+ }
+ const bucket = buckets[randomInt(buckets.length)];
+ return bucket[randomInt(bucket.length)] ?? null;
+}
+
+/**
+ * 每日一题:由种子(日期 + 用户)确定性派生的抽题结果。
+ *
+ * 不对称是刻意的:随机抽题用 crypto 随机数(不可预测),每日一题必须可复现
+ * (刷新不能换题),所以用哈希。每日一题本来就不是秘密测试 ——
+ * 它同时发给所有用户,且 `problemset.problems` 是公开接口。
+ * 真正需要不可预测的是挑战模式。
+ */
+export function dailyDanCandidate(
+ pool: readonly ProblemRating[],
+ options: DanPoolOptions,
+ seed: string,
+): ProblemRating | null {
+ const buckets = groupDanCandidates(pool, options);
+ if (buckets.length === 0) return null;
+ const digest = createHash('sha256').update(seed).digest();
+ const bucket = buckets[digest.readUInt32BE(0) % buckets.length];
+ return bucket[digest.readUInt32BE(4) % bucket.length] ?? null;
+}
+
+export type DanErrorCode =
+ | 'TIER_UNKNOWN'
+ | 'CUSTOM_INVALID'
+ | 'SESSION_ACTIVE'
+ | 'SESSION_NOT_FOUND'
+ | 'POOL_EMPTY'
+ | 'NOTHING_TO_CLAIM'
+ | 'ALREADY_CLAIMED';
+
+export class DanError extends Error {
+ code: DanErrorCode;
+ constructor(code: DanErrorCode, message: string) {
+ super(message);
+ this.code = code;
+ }
+}
+
+export interface DanSessionRow {
+ id: string; user_id: number; tier: string; kind: DanKind;
+ stage_count: number; limit_seconds: number; min_rating: number; max_rating: number;
+ status: 'active' | 'cleared' | 'failed' | 'abandoned';
+ started_at: number; finished_at: number | null;
+ total_rating: number | null; settlement_json: string | null;
+ /** 自定义抽题的标签条件(JSON 数组)。NULL = 没有标签条件(迁移 020 之前的行也是)。 */
+ tags_json: string | null;
+}
+
+export interface DanStageRow {
+ id: number; session_id: string; stage_index: number;
+ problem_id: string; difficulty: number; drawn_at: number;
+ claimed_at: number | null; timer_id: string | null;
+ outcome: 'cleared' | 'timeout' | 'interrupted' | null;
+ seconds: number | null; score_json: string | null;
+ /** 这一道的限时(秒)。NULL = 019 之前的旧行,沿用 session.limit_seconds。 */
+ limit_seconds: number | null;
+}
+
+function transaction(db: DatabaseSync, action: () => T): T {
+ db.exec('SAVEPOINT dan_write');
+ try { const result = action(); db.exec('RELEASE dan_write'); return result; }
+ catch (error) { db.exec('ROLLBACK TO dan_write; RELEASE dan_write'); throw error; }
+}
+
+function sessionRow(db: DatabaseSync, id: string): DanSessionRow | undefined {
+ return db.prepare('SELECT * FROM dan_sessions WHERE id=?').get(id) as unknown as DanSessionRow | undefined;
+}
+
+function stageRows(db: DatabaseSync, sessionId: string): DanStageRow[] {
+ return db.prepare('SELECT * FROM dan_stages WHERE session_id=? ORDER BY stage_index').all(sessionId) as unknown as DanStageRow[];
+}
+
+export function activeDanSession(db: DatabaseSync, userId: number): DanSessionRow | null {
+ return (db.prepare("SELECT * FROM dan_sessions WHERE user_id=? AND status='active'").get(userId) as unknown as DanSessionRow | undefined) ?? null;
+}
+
+/**
+ * 「已经见过的题」——任何提交(不只是 AC)都排除。
+ *
+ * 拿到 WA 也算见过,重新抽到它就失去了「抽题」的意义;而只排 AC 会让
+ * 「做过但没做出来」的题反复出现。
+ *
+ * 这个集**故意不看 `accounts.is_archived`**,与其他地方「只算当前账号」的口径
+ * 不一样(B50、题友圈、练习记录都带 `is_archived=0`)。理由:归档的含义是
+ * 「这个账号不再同步了」(换 handle、换号时旧账号会被归档),**不是**
+ * 「那些题我没做过」。带上 `is_archived=0` 的后果是:换过一次 handle 的人,
+ * 之前 AC 过的题会全部重新变成可抽 —— 这正是「别抽我做过的题」要防的事。
+ *
+ * 另外把**正在计时的题**也排除:计时可以不经提交直接对题号开始,
+ * 于是不看这一处的话,段位有可能抽到你手上正在做的那道。
+ */
+export function danExcludedProblems(db: DatabaseSync, userId: number, sessionId?: string): Set {
+ const rows = db.prepare(`SELECT DISTINCT s.problem_id FROM submissions s JOIN accounts a ON a.id=s.account_id
+ WHERE a.user_id=? AND s.platform='codeforces'`).all(userId) as { problem_id: string }[];
+ const exclude = new Set(rows.map((row) => String(row.problem_id).toUpperCase()));
+ const timers = db.prepare('SELECT DISTINCT problem_id FROM practice_timers WHERE user_id=?')
+ .all(userId) as { problem_id: string }[];
+ for (const row of timers) exclude.add(String(row.problem_id).toUpperCase());
+ if (sessionId) {
+ const drawn = db.prepare('SELECT problem_id FROM dan_stages WHERE session_id=?').all(sessionId) as { problem_id: string }[];
+ for (const row of drawn) exclude.add(String(row.problem_id).toUpperCase());
+ }
+ return exclude;
+}
+
+/** 每日一题难度带的半宽:以等效 Rating 为中心,上下各这个数。 */
+export const DAILY_BAND_RADIUS = 200;
+
+/** 每日一题的难度带:中心 + 上下界(已裁进档位并集 800–2600)。 */
+export interface DailyBand {
+ minRating: number;
+ maxRating: number;
+ /** 中心取整到 100 之后的值。null = 没有水平数据,退回全段。 */
+ center: number | null;
+ /** 榜算出的等效 Rating(未取整)。null = 没有任何可计分的成绩。 */
+ equivalentRating: number | null;
+}
+
+/**
+ * 每日一题的难度带:**以用户当前的等效 Rating 为中心 ±200**,裁进 [800, 2600]。
+ *
+ * 「不能太简单也不能太难」的落法。两端都裁进档位并集,于是中心特别低时带子
+ * 收敛到 [800, 800](水平还不到 800 就从最简单的抽),特别高时收敛到 [2600, 2600]
+ * (2600 以上是 T97 外推段,档位刻意不往上走,每日一题跟着同一口径)。
+ *
+ * 中心**取整到 100**:题目 rating 本来就是 100 的倍数,取整不丢信息;更重要的是
+ * 这让带子对 DX Rating 的小幅漂移不敏感 —— 一次同步把分数从 1543 推到 1549,
+ * 今天的题不会跟着换;跨过 1550 这道坎(取整到 1600)才会挪一次
+ * (见 docs/dan.md「每日一题按水平定带」)。
+ */
+export function dailyBand(equivalentRating: number | null): DailyBand {
+ if (equivalentRating === null || !Number.isFinite(equivalentRating)) {
+ return { minRating: DAILY_TIER.minRating, maxRating: DAILY_TIER.maxRating, center: null, equivalentRating: null };
+ }
+ const center = Math.round(equivalentRating / 100) * 100;
+ return {
+ minRating: Math.min(Math.max(center - DAILY_BAND_RADIUS, DAILY_TIER.minRating), DAILY_TIER.maxRating),
+ maxRating: Math.max(Math.min(center + DAILY_BAND_RADIUS, DAILY_TIER.maxRating), DAILY_TIER.minRating),
+ center,
+ equivalentRating,
+ };
+}
+
+/** 每日一题:确定性抽题 + 该用户未见过。不落库(读接口不能写,见口径 A 与 GET 只读约束)。 */
+export function dailyProblem(
+ db: DatabaseSync,
+ userId: number,
+ pool: readonly ProblemRating[],
+ dateKey: string,
+ band?: { minRating: number; maxRating: number },
+): ProblemRating | null {
+ const range = band ?? DAILY_TIER;
+ return dailyDanCandidate(
+ pool,
+ { minRating: range.minRating, maxRating: range.maxRating, exclude: danExcludedProblems(db, userId) },
+ `daily:${dateKey}:${userId}`,
+ );
+}
+
+/** 自定义抽题的输入校验:范围与标签的**形状**。标签是否真实存在于题库,由 API 层对着题库验。 */
+function validateCustomInput(input: { minRating?: number; maxRating?: number; tags?: readonly string[] }): {
+ minRating: number; maxRating: number; tags: string[];
+} {
+ const { minRating, maxRating } = input;
+ if (!Number.isSafeInteger(minRating) || !Number.isSafeInteger(maxRating)) {
+ throw new DanError('CUSTOM_INVALID', '自定义抽题要给难度上下限(整数)');
+ }
+ if (minRating < DAN_CUSTOM_MIN_RATING || maxRating > DAN_CUSTOM_MAX_RATING || minRating > maxRating) {
+ throw new DanError('CUSTOM_INVALID',
+ `难度范围要在 ${DAN_CUSTOM_MIN_RATING}–${DAN_CUSTOM_MAX_RATING} 之内,且下限不超过上限`);
+ }
+ const tags = input.tags ?? [];
+ if (!Array.isArray(tags) || tags.length > DAN_CUSTOM_MAX_TAGS) {
+ throw new DanError('CUSTOM_INVALID', `标签最多选 ${DAN_CUSTOM_MAX_TAGS} 个`);
+ }
+ for (const tag of tags) {
+ if (typeof tag !== 'string' || !tag.trim() || tag.length > 40) throw new DanError('CUSTOM_INVALID', '标签格式无效');
+ }
+ return { minRating, maxRating, tags: tags.map((tag) => tag.trim()) };
+}
+
+export function createDanSession(
+ db: DatabaseSync,
+ input: { id: string; userId: number; kind: DanKind; tierKey: string; now: number;
+ minRating?: number; maxRating?: number; tags?: readonly string[] },
+): DanSessionRow {
+ const custom = input.tierKey === DAN_CUSTOM_TIER.key;
+ if (custom && input.kind !== 'single') throw new DanError('CUSTOM_INVALID', '自定义条件只用于单题抽题');
+ const customRange = custom ? validateCustomInput(input) : null;
+ const tier = input.kind === 'daily' ? DAILY_TIER
+ : custom ? DAN_CUSTOM_TIER : selectableDanTier(input.tierKey);
+ if (!tier) throw new DanError('TIER_UNKNOWN', '不认识的档位');
+ return transaction(db, () => {
+ if (activeDanSession(db, input.userId)) {
+ throw new DanError('SESSION_ACTIVE', '已经有一轮认定没结束,先完成或放弃它');
+ }
+ db.prepare(`INSERT INTO dan_sessions
+ (id,user_id,tier,kind,stage_count,limit_seconds,min_rating,max_rating,status,started_at,tags_json)
+ VALUES(?,?,?,?,?,?,?,?,'active',?,?)`)
+ .run(input.id, input.userId, tier.key, input.kind, danStageCount(input.kind),
+ tier.limitSeconds,
+ customRange ? customRange.minRating : tier.minRating,
+ customRange ? customRange.maxRating : tier.maxRating,
+ input.now,
+ customRange && customRange.tags.length ? JSON.stringify(customRange.tags) : null);
+ return sessionRow(db, input.id)!;
+ });
+}
+
+/**
+ * 抽下一道题并落库。返回 null 表示这一轮不需要再抽(已结束,或当前那道还没结算)。
+ *
+ * 抽题结果**只落库**,调用方负责只把 `difficulty` 发出去(口径 A)。
+ * 抽不到题(区间内全做过、或题库为空)抛 `POOL_EMPTY`,不静默降级成区间外的题。
+ */
+export function drawNextDanStage(
+ db: DatabaseSync,
+ input: { sessionId: string; pool: readonly ProblemRating[]; randomInt?: DanRandomInt; now: number; dateKey: string },
+): DanStageRow | null {
+ return transaction(db, () => {
+ const session = sessionRow(db, input.sessionId);
+ if (!session || session.status !== 'active') return null;
+ const stages = stageRows(db, session.id);
+ // 当前那道还没 claim / 还没结算,就不再抽 —— 否则会出现两道同时在手的题。
+ if (stages.length > 0 && stages[stages.length - 1].outcome === null) return null;
+ if (stages.length >= session.stage_count) return null;
+ const tier = danTier(session.tier);
+ // 自定义抽题的标签条件存在轮次上(迁移 020);没有条件或旧行(NULL)都不筛。
+ const tagList = session.tags_json ? JSON.parse(session.tags_json) as string[] : [];
+ const options: DanPoolOptions = {
+ minRating: session.min_rating,
+ maxRating: session.max_rating,
+ exclude: danExcludedProblems(db, session.user_id, session.id),
+ draw: tier?.draw ?? 'rating',
+ tags: tagList.length ? new Set(tagList) : undefined,
+ };
+ // 每日一题必须**可复现**(同一天刷新不能换题),所以走哈希而不是随机数;
+ // 其余模式走 crypto 随机数,服务端不可预测(口径 A)。
+ const candidate = session.kind === 'daily'
+ ? dailyDanCandidate(input.pool, options, `daily:${input.dateKey}:${session.user_id}`)
+ : drawDanCandidate(input.pool, options, input.randomInt);
+ if (!candidate) throw new DanError('POOL_EMPTY', '这个区间里已经没有你没做过的题了');
+ // 限时随题走(随机段位),抽到的那一刻就定下来 —— 与 difficulty 一样是快照,
+ // 之后调档位区间不会把这一轮的规则重新解释一遍。
+ const limit = danStageLimit(tier, candidate.rating, session.limit_seconds);
+ const info = db.prepare(`INSERT INTO dan_stages(session_id,stage_index,problem_id,difficulty,drawn_at,limit_seconds)
+ VALUES(?,?,?,?,?,?)`)
+ .run(session.id, stages.length + 1, danProblemId(candidate), candidate.rating, input.now, limit);
+ return db.prepare('SELECT * FROM dan_stages WHERE id=?').get(info.lastInsertRowid) as unknown as DanStageRow;
+ });
+}
+
+/** 可开始的那一道:最新一条还没 claim 的记录。**只给服务端用**,不要把 problem_id 发出去。 */
+export function claimableDanStage(db: DatabaseSync, sessionId: string): DanStageRow | null {
+ const row = db.prepare(`SELECT * FROM dan_stages WHERE session_id=? AND claimed_at IS NULL AND outcome IS NULL
+ ORDER BY stage_index DESC LIMIT 1`).get(sessionId) as unknown as DanStageRow | undefined;
+ return row ?? null;
+}
+
+/**
+ * 当前这一道:最新一条还没结算的记录(可能已经开始,也可能还没)。
+ *
+ * 与 `claimableDanStage` 分开是为了让「开始做题」幂等:重复点击时拿到的还是同一道,
+ * 于是能返回同一个链接,而不是报「没有待开始的题目」。
+ */
+export function currentDanStage(db: DatabaseSync, sessionId: string): DanStageRow | null {
+ const row = db.prepare(`SELECT * FROM dan_stages WHERE session_id=? AND outcome IS NULL
+ ORDER BY stage_index DESC LIMIT 1`).get(sessionId) as unknown as DanStageRow | undefined;
+ return row ?? null;
+}
+
+/** 锁题:记下开始时刻与计时器 id。计时器由调用方先建好(`startTimer`)。 */
+export function claimDanStage(
+ db: DatabaseSync,
+ input: { sessionId: string; timerId: string; now: number },
+): DanStageRow {
+ return transaction(db, () => {
+ const stage = claimableDanStage(db, input.sessionId);
+ if (!stage) throw new DanError('NOTHING_TO_CLAIM', '没有待开始的题目,请刷新');
+ db.prepare('UPDATE dan_stages SET claimed_at=?,timer_id=? WHERE id=? AND claimed_at IS NULL')
+ .run(input.now, input.timerId, stage.id);
+ return db.prepare('SELECT * FROM dan_stages WHERE id=?').get(stage.id) as unknown as DanStageRow;
+ });
+}
+
+/**
+ * 结算所有进行中的认定:把已结束的计时器落成阶段成绩,再决定整轮的胜负。
+ *
+ * 由 `reconcileTimers`(timer.ts)在同一个事务之外调用 —— 所以它自己开 SAVEPOINT。
+ * 它**不抽题**:抽题要题库(网络),而计时器结算不该依赖网络。新一道由读接口
+ * 按需抽(见口径 A)。
+ */
+export function advanceDanSessions(db: DatabaseSync, now: number): void {
+ transaction(db, () => {
+ const sessions = db.prepare("SELECT * FROM dan_sessions WHERE status='active'").all() as unknown as DanSessionRow[];
+ for (const session of sessions) settleDanSession(db, session, now);
+ });
+}
+
+function settleDanSession(db: DatabaseSync, session: DanSessionRow, now: number): void {
+ // 整轮超期(抽了不点)就作废,否则会永久占住 `one_active_dan_per_user`。
+ // 放在循环外:单题限时最长 3600 秒,所以整轮超期时每道题的限时必然也已经过了。
+ if (now > session.started_at + DAN_SESSION_TTL_SECONDS) {
+ for (const stage of stageRows(db, session.id)) {
+ if (stage.outcome !== null) continue;
+ if (stage.timer_id) {
+ db.prepare("UPDATE practice_timers SET status='cancelled',ended_at=? WHERE id=? AND status='running'")
+ .run(now, stage.timer_id);
+ }
+ db.prepare("UPDATE dan_stages SET outcome='interrupted' WHERE id=?").run(stage.id);
+ }
+ finishDanSession(db, session, 'abandoned', now);
+ return;
+ }
+ for (const stage of stageRows(db, session.id)) {
+ if (stage.outcome !== null) continue;
+ if (stage.claimed_at === null || stage.timer_id === null) continue; // 还没开始做
+ const timer = db.prepare('SELECT * FROM practice_timers WHERE id=?').get(stage.timer_id) as unknown as
+ { status: string; started_at: number; ended_at: number | null } | undefined;
+ if (!timer) { db.prepare("UPDATE dan_stages SET outcome='interrupted' WHERE id=?").run(stage.id); continue; }
+ if (timer.status === 'running') {
+ const deadline = stage.claimed_at + danStageLimitOf(stage, session);
+ if (now <= deadline) continue; // 还在限时内
+ // 超时取消:段位限时是 dan 这一层的规则,语句与 cancelTimer(timer.ts)一致。
+ db.prepare("UPDATE practice_timers SET status='cancelled',ended_at=? WHERE id=? AND status='running'")
+ .run(deadline, stage.timer_id);
+ db.prepare("UPDATE dan_stages SET outcome='timeout' WHERE id=?").run(stage.id);
+ continue;
+ }
+ if (timer.status === 'completed' && timer.ended_at !== null) {
+ const seconds = timer.ended_at - timer.started_at;
+ const score = scoreProblem({
+ platform: 'codeforces', problemId: stage.problem_id, problemTitle: stage.problem_id, problemUrl: null,
+ problemRating: stage.difficulty, solvedAt: timer.ended_at, releasedAt: null, recordedSeconds: seconds,
+ });
+ db.prepare("UPDATE dan_stages SET outcome='cleared',seconds=?,score_json=? WHERE id=?")
+ .run(seconds, JSON.stringify(score), stage.id);
+ continue;
+ }
+ // cancelled / expired:这一轮被打断,判为放弃。
+ db.prepare("UPDATE dan_stages SET outcome='interrupted' WHERE id=?").run(stage.id);
+ }
+
+ const stages = stageRows(db, session.id);
+ const interrupted = stages.find((stage) => stage.outcome === 'interrupted');
+ if (interrupted) { finishDanSession(db, session, 'abandoned', now); return; }
+ const timedOut = stages.find((stage) => stage.outcome === 'timeout');
+ if (timedOut) { finishDanSession(db, session, 'failed', now); return; }
+ if (stages.length < session.stage_count || stages.some((stage) => stage.outcome !== 'cleared')) return;
+ finishDanSession(db, session, 'cleared', now);
+}
+
+/** 冻结结算:整轮的成绩写入 settlement_json,之后读到的都是当时的规则与数值。 */
+function finishDanSession(db: DatabaseSync, session: DanSessionRow, status: 'cleared' | 'failed' | 'abandoned', now: number): void {
+ const stages = stageRows(db, session.id);
+ const scores = stages.map((stage) => {
+ const score = stage.score_json ? JSON.parse(stage.score_json) as Record : null;
+ return {
+ index: stage.stage_index,
+ problemId: stage.problem_id,
+ difficulty: stage.difficulty,
+ outcome: stage.outcome,
+ seconds: stage.seconds,
+ // 逐题限时进结算快照:随机段位每题限时不同,历史记录要能按当时的规则解释。
+ limitSeconds: danStageLimitOf(stage, session),
+ achievementShown: score?.achievementShown ?? null,
+ rank: score?.rank ?? null,
+ rating: score?.rating ?? null,
+ };
+ });
+ const cleared = status === 'cleared';
+ const total = cleared
+ ? Math.round(scores.reduce((sum, row) => sum + (typeof row.rating === 'number' ? row.rating : 0), 0) * 10) / 10
+ : null;
+ const settlement = {
+ tier: session.tier, kind: session.kind, status,
+ stageCount: session.stage_count, limitSeconds: session.limit_seconds,
+ minRating: session.min_rating, maxRating: session.max_rating,
+ // 自定义抽题的标签条件进快照:历史记录要能按当时的条件解释。
+ tags: session.tags_json ? JSON.parse(session.tags_json) as string[] : null,
+ startedAt: session.started_at, finishedAt: now, totalRating: total, stages: scores,
+ selfReported: true,
+ };
+ db.prepare('UPDATE dan_sessions SET status=?,finished_at=?,total_rating=?,settlement_json=? WHERE id=? AND status=\'active\'')
+ .run(status, now, total, JSON.stringify(settlement), session.id);
+}
+
+export function abandonDanSession(db: DatabaseSync, input: { sessionId: string; userId: number; now: number }): void {
+ transaction(db, () => {
+ const session = sessionRow(db, input.sessionId);
+ if (!session || session.user_id !== input.userId) throw new DanError('SESSION_NOT_FOUND', '这一轮认定不存在');
+ if (session.status !== 'active') return;
+ for (const stage of stageRows(db, session.id)) {
+ if (stage.outcome !== null) continue;
+ if (stage.timer_id) {
+ db.prepare("UPDATE practice_timers SET status='cancelled',ended_at=? WHERE id=? AND status='running'")
+ .run(input.now, stage.timer_id);
+ db.prepare("UPDATE dan_stages SET outcome='interrupted' WHERE id=?").run(stage.id);
+ }
+ }
+ finishDanSession(db, session, 'abandoned', input.now);
+ });
+}
+
+export interface DanStageView {
+ index: number;
+ /** 抽到的题目难度。**这是「开始做题」之前唯一允许下发的信息**(口径 A)。 */
+ difficulty: number;
+ /** 这一道的限时(秒)。随机段位逐题不同,按当题难度定。 */
+ limitSeconds: number;
+ claimed: boolean;
+ outcome: 'cleared' | 'timeout' | 'interrupted' | null;
+ seconds: number | null;
+ achievement: number | null;
+ rank: string | null;
+ rating: number | null;
+ /** null = 还没开始做题,题号与链接都不下发。 */
+ problemId: string | null;
+ problemUrl: string | null;
+ deadlineAt: number | null;
+ /** 题目名,来自已同步的提交;结算卡要显示。 */
+ title: string | null;
+ /** 这一次计时窗口内的判定计数 / WA 数,与普通计时结算同一口径。 */
+ verdicts: Record;
+ waCount: number;
+ /**
+ * 计时器结算时冻结的 B50 对比(`{year,previousScore,currentScore,ratingBefore,ratingAfter,ratingDelta}`)。
+ * 段位認定不另算一套分:这里读的就是 `reconcileTimers` 写进 `practice_timers.settlement_json` 的那份。
+ */
+ comparison: Record | null;
+ practiceKind: string | null;
+}
+
+export interface DanSessionView {
+ id: string;
+ tier: string;
+ tierName: string;
+ kind: DanKind;
+ status: DanSessionRow['status'];
+ stageCount: number;
+ limitSeconds: number;
+ minRating: number;
+ maxRating: number;
+ startedAt: number;
+ finishedAt: number | null;
+ expiresAt: number;
+ totalRating: number | null;
+ /** 抽题分布(口径 B)。`problem` 只可能是大随机段位。 */
+ draw: 'rating' | 'problem';
+ /** true = 逐题限时按当题难度,此时 `limitSeconds` 只是展示回退值。 */
+ perStageLimit: boolean;
+ /** 自定义抽题的标签条件;null = 没有条件。 */
+ tags: string[] | null;
+ stages: DanStageView[];
+ /** 结算只出单题成绩与总分,**不发段位名**(随机段位認定是挑战模式)。 */
+ selfReported: true;
+}
+
+export function danSessionView(db: DatabaseSync, session: DanSessionRow): DanSessionView {
+ const tier = danTier(session.tier);
+ return {
+ id: session.id,
+ tier: session.tier,
+ tierName: tier?.name ?? session.tier,
+ kind: session.kind,
+ status: session.status,
+ stageCount: session.stage_count,
+ limitSeconds: session.limit_seconds,
+ minRating: session.min_rating,
+ maxRating: session.max_rating,
+ startedAt: session.started_at,
+ finishedAt: session.finished_at,
+ expiresAt: session.started_at + DAN_SESSION_TTL_SECONDS,
+ totalRating: session.total_rating,
+ draw: tier?.draw ?? 'rating',
+ perStageLimit: tier?.perStageLimit === true,
+ tags: session.tags_json ? JSON.parse(session.tags_json) as string[] : null,
+ stages: stageRows(db, session.id).map((stage) => danStageView(db, stage, session)),
+ selfReported: true,
+ };
+}
+
+function danStageView(db: DatabaseSync, stage: DanStageRow, session: DanSessionRow): DanStageView {
+ const score = stage.score_json ? JSON.parse(stage.score_json) as Record : null;
+ // 口径 A:没 claim 过就不下发题号与链接。
+ const revealed = stage.claimed_at !== null;
+ const timer = stage.timer_id
+ ? db.prepare('SELECT * FROM practice_timers WHERE id=?').get(stage.timer_id) as unknown as
+ { account_id: number; started_at: number; ended_at: number | null; attempt_id: string | null;
+ practice_kind: string; settlement_json: string | null } | undefined
+ : undefined;
+ const problem = revealed
+ ? db.prepare(`SELECT MAX(problem_title) AS title FROM submissions
+ WHERE platform='codeforces' AND problem_id=?`).get(stage.problem_id) as unknown as { title: string | null } | undefined
+ : undefined;
+ const verdicts: Record = {};
+ if (timer?.ended_at !== null && timer?.ended_at !== undefined) {
+ const rows = db.prepare(`SELECT status,COUNT(*) AS count FROM submissions
+ WHERE account_id=? AND platform='codeforces' AND problem_id=? AND submitted_at>? AND submitted_at<=?
+ GROUP BY status`).all(timer.account_id, stage.problem_id, timer.started_at, timer.ended_at) as unknown as
+ { status: string; count: number }[];
+ for (const row of rows) verdicts[String(row.status)] = Number(row.count);
+ }
+ const attempt = timer?.attempt_id
+ ? db.prepare('SELECT practice_kind FROM practice_attempts WHERE id=?').get(timer.attempt_id) as unknown as
+ { practice_kind: string } | undefined
+ : undefined;
+ return {
+ index: stage.stage_index,
+ difficulty: stage.difficulty,
+ limitSeconds: danStageLimitOf(stage, session),
+ claimed: revealed,
+ outcome: stage.outcome,
+ seconds: stage.seconds,
+ achievement: typeof score?.achievementShown === 'number' ? score.achievementShown : null,
+ rank: typeof score?.rank === 'string' ? score.rank : null,
+ rating: typeof score?.rating === 'number' ? score.rating : null,
+ problemId: revealed ? stage.problem_id : null,
+ problemUrl: revealed ? danProblemUrl(stage.problem_id) : null,
+ deadlineAt: stage.claimed_at === null ? null : stage.claimed_at + danStageLimitOf(stage, session),
+ title: revealed ? (problem?.title ?? null) : null,
+ verdicts,
+ waCount: verdicts.WA ?? 0,
+ comparison: timer?.settlement_json ? JSON.parse(timer.settlement_json) as Record : null,
+ practiceKind: attempt?.practice_kind ?? timer?.practice_kind ?? null,
+ };
+}
+
+export function danHistory(db: DatabaseSync, userId: number, limit = 20): DanSessionView[] {
+ const rows = db.prepare(`SELECT * FROM dan_sessions WHERE user_id=? AND status!='active'
+ ORDER BY started_at DESC LIMIT ?`).all(userId, limit) as unknown as DanSessionRow[];
+ return rows.map((row) => danSessionView(db, row));
+}
\ No newline at end of file
diff --git a/src/dx/timer.ts b/src/dx/timer.ts
index 9a5c12f..d7fc30a 100644
--- a/src/dx/timer.ts
+++ b/src/dx/timer.ts
@@ -1,6 +1,7 @@
import type { DatabaseSync } from 'node:sqlite';
import { recordPractice, type PracticeInput } from './practice.ts';
import { scoreProblem, buildBoard } from './rating.ts';
+import { advanceDanSessions } from './dan.ts';
import { listDxEntries } from '../server/queries.ts';
export interface PracticeTimer {
@@ -127,6 +128,10 @@ export function reconcileTimers(db: DatabaseSync, accountId?: number, now = nowS
}
}
});
+ // 挑战/段位認定:计时器落成 completed 之后,那一轮才知道自己第几道做完了。
+ // 放在事务外,且**不调 cancelTimer** —— 它会回调本函数,形成递归
+ // (超时取消在 advanceDanSessions 里直接置状态,见 dan.ts)。
+ advanceDanSessions(db, now);
}
export function cancelTimer(db: DatabaseSync, userId: number, id: string) {
diff --git a/src/fetchers/codeforces-sync.ts b/src/fetchers/codeforces-sync.ts
index dd68a9f..5ad7730 100644
--- a/src/fetchers/codeforces-sync.ts
+++ b/src/fetchers/codeforces-sync.ts
@@ -9,9 +9,26 @@ const CONTEST_LIST_URL = 'https://codeforces.com/api/contest.list?gym=false';
/** problemset.problems 一次返回全量题目(含历史);实测 ~9000 题 / 1.5 MB。 */
const PROBLEMSET_URL = 'https://codeforces.com/api/problemset.problems';
/** 全量评级缓存 6 小时:与 contest.list 同一条规矩 —— 既成事实,没必要次次抓。 */
-const RATINGS_CACHE_KEY = 'cf:problemset-ratings:v1';
+/**
+ * 全量题库的缓存键。导出是为了让调用方(读接口、测试)能直接定位这份缓存,
+ * 而不必在别处硬编码同一个字符串。
+ */
+export const PROBLEM_RATINGS_CACHE_KEY = 'cf:problemset-ratings:v2';
+const RATINGS_CACHE_KEY = PROBLEM_RATINGS_CACHE_KEY;
const RATINGS_TTL_SECONDS = 6 * 3600;
+/**
+ * 只读读缓存里的全量题库,**不抓网络**。
+ *
+ * 抽题/每日一题的服务端读接口(GET)用它:那些接口挂在只读连接上
+ * (`PRAGMA query_only = ON`),调 `fetch_problem_ratings` 会因为写 `fetch_cache` 而失败。
+ * 缓存冷时返回 null,由调用方决定是提示「题库未就绪」还是走写接口去抓一次。
+ * 键留在本文件,避免别处硬编码同一个字符串。
+ */
+export function cachedProblemRatings(cache: Cache): ProblemRating[] | null {
+ return cache.get(RATINGS_CACHE_KEY) ?? null;
+}
+
// Keep the Phase 1 standalone interface compatible; sync uses shared DB rate slots.
export class CodeforcesSyncFetcher extends CodeforcesFetcher {
http:HttpClient;
@@ -58,7 +75,9 @@ export class CodeforcesSyncFetcher extends CodeforcesFetcher {
const rows:ProblemRating[]=[];
for(const p of problems){
if(!Number.isSafeInteger(p?.contestId)||typeof p?.index!=='string'||!Number.isSafeInteger(p?.rating))continue;
- rows.push({contestId:p.contestId,index:p.index,rating:p.rating});
+ // 标签原样带走(自定义抽题按它筛)。非字符串的一律丢掉,不让脏数据进缓存。
+ const tags=Array.isArray(p.tags)?p.tags.filter((t:any):t is string=>typeof t==='string'):[];
+ rows.push({contestId:p.contestId,index:p.index,rating:p.rating,tags});
}
this.cache.set(RATINGS_CACHE_KEY,rows,RATINGS_TTL_SECONDS);
return rows;
diff --git a/src/server/api.ts b/src/server/api.ts
index 3e1f35c..e1cef62 100644
--- a/src/server/api.ts
+++ b/src/server/api.ts
@@ -72,6 +72,32 @@ import type { SyncJobRunner, SyncJobState } from './sync-job.ts';
import { listPractice, recordPractice, editPractice, voidPractice, type PracticeInput } from '../dx/practice.ts';
import { localBacktestReport } from '../dx/backtest.ts';
import { startTimer, timerState, cancelTimer, reconcileTimers } from '../dx/timer.ts';
+import {
+ DAILY_TIER,
+ DAN_CUSTOM_TIER,
+ DAN_RANDOM_TIERS,
+ DAN_STAGES,
+ DAN_TIERS,
+ DanError,
+ abandonDanSession,
+ activeDanSession,
+ advanceDanSessions,
+ claimDanStage,
+ createDanSession,
+ currentDanStage,
+ danDateKey,
+ danHistory,
+ danProblemUrl,
+ danSessionView,
+ danStageLimitOf,
+ dailyBand,
+ dailyProblem,
+ drawNextDanStage,
+ type DanKind,
+} from '../dx/dan.ts';
+import { CodeforcesSyncFetcher, cachedProblemRatings } from '../fetchers/codeforces-sync.ts';
+import { Repository } from '../db/database.ts';
+import type { ProblemRating } from '../domain.ts';
import { dismissedProblems, reminderGroup, REMINDER_DAYS, setTimeReminder } from '../dx/reminders.ts';
export interface ApiContext {
@@ -145,6 +171,11 @@ export const WRITE_ROUTES = new Set([
'/api/dx/timer/check',
'/api/dx/attempts/void',
'/api/dx/attempts/edit',
+ '/api/dan/start',
+ '/api/dan/claim',
+ '/api/dan/settle',
+ '/api/dan/next',
+ '/api/dan/abandon',
]);
const ADMIN_MESSAGES: Record = {
@@ -664,6 +695,111 @@ function exportBacktest(ctx: ApiContext, params: URLSearchParams): ApiResult {
year, yearStartSeconds(year), yearStartSeconds(year + 1)) };
}
+/* ---------- 随机抽题 / 每日一题 / 段位認定 ---------- */
+
+/**
+ * 抽题的题库:复用 `problemset.problems` 那份 6 小时缓存,**不新增数据源**。
+ *
+ * 只走写连接 —— 缓存过期时 `fetch_problem_ratings` 会去抓一次并写 `fetch_cache`,
+ * 而 GET 那条连接是 `PRAGMA query_only = ON`(见本文件顶部)。
+ */
+async function danPool(db: DatabaseSync): Promise {
+ return (await new CodeforcesSyncFetcher(new Repository(db), new HttpClient(db)).fetch_problem_ratings()) ?? [];
+}
+
+/** 段位認定/每日一题的时区偏移:越界就退回默认,和其它接口一样不报错。 */
+function tzFromBody(input: Record): number {
+ const value = input.tz;
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) return DEFAULT_TZ_OFFSET_MINUTES;
+ return Math.max(-840, Math.min(840, value));
+}
+
+/** 会话 id 只出 [a-f0-9],因为它会被拼进计时器的 requestId,那个字段有字符集校验。 */
+function newDanSessionId(): string {
+ return randomBytes(12).toString('hex');
+}
+
+/** 每道题一个确定性的计时器 id:重复点「开始做题」命中同一条计时,不会起第二个。 */
+function danTimerRequestId(sessionId: string, stageIndex: number): string {
+ return `dan-${sessionId}-${stageIndex}`;
+}
+
+/**
+ * 用户当前的「等效 Rating」:B50 榜的平均单题 rating × 50。
+ *
+ * 为什么不直接用 DX Rating(50 格之和):榜没填满时那个数是**求和**口径 ——
+ * 四道 1700 分的题总分只有 ~136,拿它当每日一题的难度带中心会让带子整天贴着 800。
+ * 「单题 rating × 50 = 等效 CF Rating」是评分模型自己的锚(rating.ts);
+ * 平均口径在榜填满 50 格时与 DX Rating **完全相等**,不满时是诚实的水平估计。
+ * 与 /api/dx 同一套分板规则(本年度出题的进 b15,其余进 b35,未出题日期按旧题)。
+ */
+function danEquivalentRating(db: DatabaseSync, userId: number): number | null {
+ const entries = listDxEntries(db, userId, DX_PLATFORM);
+ const year = new Date().getFullYear();
+ const until = yearStartSeconds(year + 1);
+ const eligible = entries.filter((e) => e.releasedAt === null || e.releasedAt < until);
+ const board = buildBoard(eligible, yearStartSeconds(year));
+ const filled = board.oldCount + board.currentCount;
+ if (!filled || !(board.rating > 0)) return null;
+ return (board.rating / filled) * PROBLEM_RATING_DIVISOR;
+}
+
+/** 题库的标签清单(名字 + 题量),按题量降序给前端做选择器;空标签不收。 */
+function danTagCatalog(pool: readonly ProblemRating[]): { name: string; count: number }[] {
+ const counts = new Map();
+ for (const problem of pool) {
+ for (const tag of problem.tags ?? []) {
+ if (!tag) continue;
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
+ }
+ }
+ return [...counts.entries()]
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
+}
+
+/**
+ * 抽题面板与记录页的只读视图。
+ *
+ * **只下发难度**:`daily` 与 `active` 里没 claim 过的题都不含题号与链接(见 dan.ts 口径 A)。
+ * 题库只从 `fetch_cache` 读(读连接不能写),冷缓存如实报告 `poolReady:false`,
+ * 由前端引导走一次写接口去抓。
+ */
+function danOverview(ctx: ApiContext, userId: number, tzOffsetMinutes: number): ApiResult {
+ if (!ctx.db.prepare('SELECT 1 FROM users WHERE id=?').get(userId)) throw new BadRequest('用户不存在');
+ const pool = cachedProblemRatings(new Repository(ctx.db));
+ const now = Math.floor(Date.now() / 1000);
+ const dateKey = danDateKey(now, tzOffsetMinutes);
+ // 每日一题的难度带按用户当前的等效 Rating 定(没有水平数据时退回全段)。
+ const band = dailyBand(danEquivalentRating(ctx.db, userId));
+ const daily = pool ? dailyProblem(ctx.db, userId, pool, dateKey, band) : null;
+ const active = activeDanSession(ctx.db, userId);
+ return {
+ status: 200,
+ body: {
+ tiers: DAN_TIERS.map((tier) => ({ key: tier.key, name: tier.name, minRating: tier.minRating,
+ maxRating: tier.maxRating, limitSeconds: tier.limitSeconds, draw: tier.draw,
+ perStageLimit: tier.perStageLimit === true })),
+ randomTiers: DAN_RANDOM_TIERS.map((tier) => ({ key: tier.key, name: tier.name, minRating: tier.minRating,
+ maxRating: tier.maxRating, limitSeconds: tier.limitSeconds, draw: tier.draw,
+ perStageLimit: tier.perStageLimit === true })),
+ customTier: { key: DAN_CUSTOM_TIER.key, minRating: DAN_CUSTOM_TIER.minRating,
+ maxRating: DAN_CUSTOM_TIER.maxRating, perStageLimit: DAN_CUSTOM_TIER.perStageLimit === true },
+ dailyBand: { minRating: band.minRating, maxRating: band.maxRating,
+ center: band.center, equivalentRating: band.equivalentRating },
+ tags: pool ? danTagCatalog(pool) : [],
+ stageCount: DAN_STAGES,
+ dateKey,
+ serverNow: now,
+ poolReady: pool !== null,
+ poolSize: pool?.length ?? 0,
+ daily: daily ? { dateKey, difficulty: daily.rating } : null,
+ active: active ? danSessionView(ctx.db, active) : null,
+ history: danHistory(ctx.db, userId, 20),
+ },
+ };
+}
+
/* ---------- 路由 ---------- */
export async function handleApi(ctx: ApiContext, request: ApiRequest): Promise {
@@ -692,6 +828,11 @@ export async function handleApi(ctx: ApiContext, request: ApiRequest): Promise typeof t === 'string') : [];
+ const known = new Set(pool.flatMap((problem) => [...(problem.tags ?? [])]));
+ const unknown = requested.filter((tag) => !known.has(tag));
+ if (unknown.length) throw new BadRequest(`题库里没有这些标签:${unknown.join('、')}`);
+ custom = { minRating: Number(input.minRating), maxRating: Number(input.maxRating), tags: requested };
+ }
+ const sessionId = newDanSessionId();
+ // 先建轮次再抽题:抽题要题库(可能触发一次网络抓取),失败时不该留下半截状态。
+ const session = createDanSession(db, { id: sessionId, userId, kind, tierKey, now, ...custom });
+ pool ??= await danPool(db);
+ const drawn = drawNextDanStage(db, { sessionId, pool, now, dateKey: danDateKey(now, tzFromBody(input)) });
+ // 只回难度。题号与链接留到 claim 那一次(口径 A)。
+ return { status: 200, body: { session: danSessionView(db, session), poolSize: pool.length,
+ stage: drawn ? { index: drawn.stage_index, difficulty: drawn.difficulty } : null } };
+ }
+ case '/api/dan/claim': {
+ const input = objectBody(body), userId = positiveInt(input, 'userId');
+ const sessionId = text(input, 'sessionId', 100), now = Math.floor(Date.now() / 1000), db = ctx.openWrite();
+ const session = activeDanSession(db, userId);
+ if (!session || session.id !== sessionId) throw new BadRequest('这一轮认定已经结束了,请刷新');
+ const pending = currentDanStage(db, sessionId);
+ if (!pending) throw new BadRequest('没有待开始的题目,请刷新');
+ // 先起计时器:起不来(没绑 CF 账号、已有计时在跑)就整件事失败,不进入锁题。
+ if (pending.claimed_at === null) {
+ const timer = startTimer(db, { userId, problemId: pending.problem_id, practiceKind: 'unknown',
+ requestId: danTimerRequestId(sessionId, pending.stage_index) }, now);
+ claimDanStage(db, { sessionId, timerId: timer.id, now });
+ }
+ // 幂等:重复点击返回同一道题的同一个链接,而不是报错。
+ const stage = currentDanStage(db, sessionId)!;
+ return { status: 200, body: {
+ sessionId, stageIndex: stage.stage_index, difficulty: stage.difficulty,
+ problemId: stage.problem_id, url: danProblemUrl(stage.problem_id), timerId: stage.timer_id,
+ deadlineAt: (stage.claimed_at ?? now) + danStageLimitOf(stage, session), serverNow: now,
+ } };
+ }
+ case '/api/dan/settle': {
+ // 只结算、不抽题。轮询用这个:出了成绩就停在结算页上,等用户自己点「抽选下一题」。
+ const input = objectBody(body), userId = positiveInt(input, 'userId');
+ const now = Math.floor(Date.now() / 1000), db = ctx.openWrite();
+ reconcileTimers(db);
+ advanceDanSessions(db, now);
+ const session = activeDanSession(db, userId);
+ const state = timerState(db, userId);
+ if (state.timer?.status === 'running') void ctx.syncJobs.checkTimer(state.timer.account_id);
+ return { status: 200, body: { session: session ? danSessionView(db, session) : null,
+ timer: state.timer, serverNow: now } };
+ }
+ case '/api/dan/next': {
+ const input = objectBody(body), userId = positiveInt(input, 'userId');
+ const now = Math.floor(Date.now() / 1000), db = ctx.openWrite();
+ // 先结算已经同步到的 AC(reconcileTimers 会顺带推进段位認定),再抽下一道。
+ reconcileTimers(db);
+ advanceDanSessions(db, now);
+ let session = activeDanSession(db, userId);
+ let poolEmpty = false;
+ if (session) {
+ const pool = await danPool(db);
+ try {
+ drawNextDanStage(db, { sessionId: session.id, pool, now, dateKey: danDateKey(now, tzFromBody(input)) });
+ } catch (error) {
+ // 区间里没题了就如实报告,让前端提示换档位,而不是静默停在一道不存在的题上。
+ if (error instanceof DanError && error.code === 'POOL_EMPTY') poolEmpty = true;
+ else throw error;
+ }
+ session = activeDanSession(db, userId);
+ }
+ // 有计时在跑就顺手催一次抓取,和 /api/dx/timer/check 同一套。
+ const state = timerState(db, userId);
+ if (state.timer?.status === 'running') void ctx.syncJobs.checkTimer(state.timer.account_id);
+ return { status: 200, body: { session: session ? danSessionView(db, session) : null, poolEmpty,
+ timer: state.timer, serverNow: now } };
+ }
+ case '/api/dan/abandon': {
+ const input = objectBody(body), userId = positiveInt(input, 'userId'), db = ctx.openWrite();
+ // 与 cancelTimer 同一条理由:先把已经同步到的 AC 结算掉,别把已完成的判成放弃。
+ reconcileTimers(db);
+ abandonDanSession(db, { sessionId: text(input, 'sessionId', 100), userId, now: Math.floor(Date.now() / 1000) });
+ return { status: 200, body: { abandoned: true, history: danHistory(db, userId, 20) } };
+ }
default:
return { status: 404, body: { error: `未知接口:${pathname}` } };
}
@@ -874,6 +1107,13 @@ export async function handleApi(ctx: ApiContext, request: ApiRequest): Promise ({ contestId: from + i, index: 'A', rating }));
+}
+
+const ANY: ReadonlySet = new Set();
+
+/** 按下发口径取视图:不限 active,结算之后也能看(结算完成时 activeDanSession 会返回 null)。 */
+function viewOf(s: ReturnType, sessionId: string) {
+ const row = s.db.prepare('SELECT * FROM dan_sessions WHERE id=?').get(sessionId) as Parameters[1];
+ return danSessionView(s.db, row);
+}
+
+/** 起计时器 + 锁题(不 AC):模拟「点了开始做题,但还没做出来」。 */
+function beginStage(s: ReturnType, sessionId: string, now: number, timerId: string) {
+ const stage = claimableDanStage(s.db, sessionId)!;
+ startTimer(s.db, { userId: s.user, problemId: stage.problem_id, practiceKind: 'unknown', requestId: timerId }, now);
+ claimDanStage(s.db, { sessionId, timerId, now });
+ return stage;
+}
+
+/** 走完一道:起计时器 → 锁题 → 在 CF 上 AC → 同步结算。 */
+function playStage(s: ReturnType, sessionId: string, now: number, seconds: number, seq: string) {
+ const stage = beginStage(s, sessionId, now, `dan-timer-${seq}`);
+ s.repo.saveSubmissions(s.account, [submission('codeforces', {
+ submission_id: `dan-sub-${seq}`, problem_id: stage.problem_id, problem_title: '段位認定',
+ status: 'AC', submitted_at: now + seconds, difficulty: stage.difficulty,
+ })]);
+ reconcileTimers(s.db, s.account, now + seconds);
+ return stage;
+}
+
+test('四个档位区间互不重叠、覆盖 800-2600,且单题限时随难度递增', () => {
+ const sorted = [...DAN_TIERS].sort((a, b) => a.minRating - b.minRating);
+ assert.equal(sorted[0].minRating, 800);
+ assert.equal(sorted[sorted.length - 1].maxRating, 2600);
+ for (let i = 1; i < sorted.length; i += 1) {
+ assert.ok(sorted[i].minRating > sorted[i - 1].maxRating,
+ `档位区间重叠:${sorted[i - 1].key} 与 ${sorted[i].key}`);
+ assert.ok(sorted[i].limitSeconds > sorted[i - 1].limitSeconds, '越难的档位限时应当更长');
+ }
+});
+
+test('抽题总是落在档位区间内', () => {
+ const tier = DAN_TIERS.find((row) => row.key === 'advanced')!;
+ const pool = [...at(1000, 20, 1), ...at(1800, 20, 100), ...at(3000, 20, 200)];
+ for (let i = 0; i < 300; i += 1) {
+ const picked = drawDanCandidate(pool, { minRating: tier.minRating, maxRating: tier.maxRating, exclude: ANY })!;
+ assert.ok(picked.rating >= tier.minRating && picked.rating <= tier.maxRating, `抽到区间外的题:${picked.rating}`);
+ }
+});
+
+test('抽题先均匀选 rating 值,再在桶内选 —— 与桶的大小无关', () => {
+ const options = { minRating: 800, maxRating: 1100, exclude: ANY };
+ // 999 道 800 分 + 1 道 1100 分。按「题目」均匀抽的话,这里几乎永远是 800。
+ const skew = [...at(800, 999, 1), { contestId: 5000, index: 'A', rating: 1100 }];
+ // 随机序列:先选最后一个 rating 桶(1100),再选桶内第 0 道。
+ const seq = [1, 0];
+ let cursor = 0;
+ const rng = () => seq[cursor++];
+ assert.equal(drawDanCandidate(skew, options, rng)!.rating, 1100);
+ // 同一题库上把桶撑平,同一个随机序列仍选到 1100 —— 结果只由「选哪个桶」决定。
+ cursor = 0;
+ assert.equal(drawDanCandidate([{ contestId: 6000, index: 'A', rating: 800 }, { contestId: 6001, index: 'A', rating: 1100 }], options, rng)!.rating, 1100);
+ cursor = 0;
+ assert.equal(drawDanCandidate(skew, options, () => 0)!.rating, 800);
+});
+
+test('大随机段位:按题目均匀,难度分布就是题库的真实分布', () => {
+ // 999 道 800 分 + 1 道 1100 分。小随机会把两个 rating 抽成五五开,
+ // 大随机按题目均匀,所以 800 占 99.9% —— 这才是「不保证难度分布」。
+ const skew = [...at(800, 999, 1), { contestId: 5000, index: 'A', rating: 1100 }];
+ const options = { minRating: 800, maxRating: 2600, exclude: ANY, draw: 'problem' as const };
+
+ let hits = 0;
+ for (let i = 0; i < 2000; i += 1) if (drawDanCandidate(skew, options)!.rating === 800) hits += 1;
+ const share = hits / 2000;
+ assert.ok(share > 0.97, `800 分的占比应当约 99.9%,实际 ${(share * 100).toFixed(1)}%`);
+
+ // 对照:同一题库上两个 rating 桶大小相等时,大随机变成五五开 —— 它只认题目数。
+ let small = 0;
+ const even = [...at(800, 500, 1), ...at(1100, 500, 5000)];
+ for (let i = 0; i < 600; i += 1) if (drawDanCandidate(even, options)!.rating === 800) small += 1;
+ assert.ok(Math.abs(small / 600 - 0.5) < 0.1, `桶大小相同时应接近五五开,实际 ${(small / 600 * 100).toFixed(1)}%`);
+
+ // 同一个随机序列,两种分布选出的不是同一道 —— 证明分支真的分开了。
+ // rating 均匀:先选桶(第 2 个 = 1100),再选桶内第 0 道。那个桶只有 1 道题,
+ // 按题目均匀时它在 1000 道里排最后,用同一个种子根本抽不到。
+ let cursor = 0;
+ const seq = () => [1, 0][cursor++] ?? 0;
+ assert.equal(drawDanCandidate(skew, { ...options, draw: 'rating' }, seq)!.rating, 1100,
+ 'rating 均匀:1100 那个桶再小也有一半机会');
+ assert.equal(drawDanCandidate(skew, options, () => 1)!.rating, 800,
+ '题目均匀:第 2 道仍然是 800');
+});
+
+test('随机段位:不分段覆盖 800-2600,逐题限时按当题难度', () => {
+ for (const tier of DAN_RANDOM_TIERS) {
+ assert.equal(tier.minRating, 800);
+ assert.equal(tier.maxRating, 2600);
+ assert.equal(tier.perStageLimit, true, '随机段位必须逐题限时,一个统一限时对两头都不公平');
+ assert.equal(selectableDanTier(tier.key), tier, '随机段位要能当档位选');
+ assert.equal(danTier(tier.key), tier);
+ }
+ assert.deepEqual(DAN_RANDOM_TIERS.map((tier) => tier.draw), ['rating', 'problem'],
+ '小随机按 rating 均匀,大随机按题目均匀');
+ // 区间取四个难度档的并集,且确实横跨全部四档。
+ assert.equal(Math.min(...DAN_TIERS.map((tier) => tier.minRating)), 800);
+ assert.equal(Math.max(...DAN_TIERS.map((tier) => tier.maxRating)), 2600);
+ for (const tier of DAN_TIERS) {
+ assert.ok(tier.maxRating >= 800 && tier.minRating <= 2600, `${tier.key} 应当落在并集里`);
+ }
+
+ // 限时按当题难度落到对应档位,且随难度单调不减。
+ const limits = [800, 1000, 1100, 1200, 1500, 1600, 2000, 2100, 2600].map(danLimitForRating);
+ assert.deepEqual(limits, [1800, 1800, 1800, 2400, 2400, 3000, 3000, 3600, 3600]);
+ for (let i = 1; i < limits.length; i += 1) assert.ok(limits[i] >= limits[i - 1]);
+});
+
+test('随机段位落库:每道的限时随抽到的难度定,并进结算快照', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'small_random', now: t0 });
+ const session = activeDanSession(s.db, s.user)!;
+ assert.equal(session.stage_count, DAN_STAGES, '随机段位也是 4 道');
+ assert.equal(session.min_rating, 800);
+ assert.equal(session.max_rating, 2600);
+
+ // 池子里放了四个不同难度的题各一道,四道抽完必然覆盖四种限时。
+ const pool = [{ contestId: 1, index: 'A', rating: 800 }, { contestId: 2, index: 'A', rating: 1300 },
+ { contestId: 3, index: 'A', rating: 1800 }, { contestId: 4, index: 'A', rating: 2400 }];
+ const rows = s.db.prepare('SELECT difficulty,limit_seconds FROM dan_stages WHERE session_id=? ORDER BY stage_index');
+ for (let i = 1; i <= DAN_STAGES; i += 1) {
+ drawNextDanStage(s.db, { sessionId: 's1', pool, now: t0, dateKey: '2026-10-09' });
+ playStage(s, 's1', t0 + i * 10000, 300, String(i));
+ }
+ const stages = rows.all('s1') as unknown as { difficulty: number; limit_seconds: number }[];
+ assert.equal(stages.length, DAN_STAGES);
+ for (const stage of stages) {
+ assert.equal(stage.limit_seconds, danLimitForRating(stage.difficulty),
+ `难度 ${stage.difficulty} 的限时应当是当题难度对应的那一档`);
+ }
+ assert.ok(new Set(stages.map((stage) => stage.limit_seconds)).size > 1, '随机段位四道不会都是同一个限时');
+
+ const view = viewOf(s, 's1');
+ assert.equal(view.perStageLimit, true);
+ assert.equal(view.draw, 'rating');
+ for (const stage of view.stages) assert.equal(stage.limitSeconds, danLimitForRating(stage.difficulty));
+
+ const settlement = JSON.parse((s.db.prepare('SELECT settlement_json FROM dan_sessions WHERE id=?').get('s1') as { settlement_json: string }).settlement_json);
+ for (const stage of settlement.stages) assert.equal(stage.limitSeconds, danLimitForRating(stage.difficulty));
+ } finally { s.db.close(); }
+});
+
+test('019 之前的旧行没有 limit_seconds,结算时沿用 session 的统一限时', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ // 手工插一行 limit_seconds 为 NULL 的记录,模拟 018 时期抽出来的「上级」档。
+ s.db.prepare(`INSERT INTO dan_sessions
+ (id,user_id,tier,kind,stage_count,limit_seconds,min_rating,max_rating,status,started_at)
+ VALUES('old',?,'advanced','single',1,3000,1600,2000,'active',?)`).run(s.user, t0);
+ s.db.prepare(`INSERT INTO dan_stages(session_id,stage_index,problem_id,difficulty,drawn_at)
+ VALUES('old',1,'1900:A',1900,?)`).run(t0);
+ const view = viewOf(s, 'old');
+ assert.equal(view.stages[0].limitSeconds, 3000, 'NULL 时回退到 session.limit_seconds');
+ assert.equal(view.perStageLimit, false);
+
+ beginStage(s, 'old', t0, 'dan-timer-old');
+ // 3000 秒内不算超时,3001 秒算 —— 证明用的确实是回退值。
+ advanceDanSessions(s.db, t0 + 3000);
+ assert.equal(activeDanSession(s.db, s.user)!.status, 'active');
+ advanceDanSessions(s.db, t0 + 3001);
+ assert.equal(activeDanSession(s.db, s.user), null);
+ assert.equal(danHistory(s.db, s.user)[0].status, 'failed');
+ } finally { s.db.close(); }
+});
+
+test('随机段位:逐题限时按当题难度,不是按 session 的统一限时', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'small_random', now: t0 });
+ // 只放一道 800 分的题:限时应当是 1800 秒(初级档),而不是 3600。
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(800, 5, 1), now: t0, dateKey: '2026-10-09' });
+ const stage = beginStage(s, 's1', t0, 'dan-timer-r1');
+ assert.equal(stage.difficulty, 800);
+ assert.equal(viewOf(s, 's1').stages[0].limitSeconds, 1800);
+ advanceDanSessions(s.db, t0 + 1800);
+ assert.equal(activeDanSession(s.db, s.user)!.status, 'active', '1800 秒内不应判负');
+ advanceDanSessions(s.db, t0 + 1801);
+ assert.equal(activeDanSession(s.db, s.user), null, '过了 1800 秒就超时(若误用 3600 就不会结束)');
+ assert.equal(danHistory(s.db, s.user)[0].stages[0].outcome, 'timeout');
+ } finally { s.db.close(); }
+});
+
+test('抽题的 rating 分布接近均匀,而不是跟着题库的题目数走', () => {
+ const pool = [...at(800, 999, 1), ...at(1100, 999, 5000)];
+ const rounds = 600;
+ const counts = new Map();
+ for (let i = 0; i < rounds; i += 1) {
+ const picked = drawDanCandidate(pool, { minRating: 800, maxRating: 1100, exclude: ANY })!;
+ counts.set(picked.rating, (counts.get(picked.rating) ?? 0) + 1);
+ }
+ assert.equal(counts.size, 2, '两个 rating 桶都应被抽到');
+ const share = (counts.get(800) ?? 0) / rounds;
+ assert.ok(share > 0.4 && share < 0.6, `800 分占比应在 40%~60%,实际 ${(share * 100).toFixed(1)}%`);
+});
+
+test('已提交过的题不再抽到,且题号大小写不敏感', () => {
+ const s = setup();
+ try {
+ // codeforces.ts 拼 problem_id 时不做大小写归一,这里刻意存小写。
+ s.repo.saveSubmissions(s.account, [submission('codeforces', {
+ submission_id: 'dan-wa-1', problem_id: '2259:a', problem_title: '见过但没做出来',
+ status: 'WA', submitted_at: 100, difficulty: 1600,
+ })]);
+ const exclude = danExcludedProblems(s.db, s.user);
+ assert.ok(exclude.has('2259:A'), '小写存储的题号也要能排除掉大写抽出结果');
+ assert.equal(drawDanCandidate([{ contestId: 2259, index: 'a', rating: 1600 }],
+ { minRating: 1600, maxRating: 1600, exclude }, () => 0), null);
+ } finally { s.db.close(); }
+});
+
+test('归档账号做过的题同样排除(归档 = 不再同步,不是「那些题我没做过」)', () => {
+ const s = setup();
+ try {
+ // 换 handle 的典型场景:旧账号被归档,新账号接管。
+ const old = s.repo.addAccount(s.user, 'codeforces', 'old-handle');
+ s.db.prepare('UPDATE accounts SET is_archived=1 WHERE id=?').run(old);
+ s.repo.saveSubmissions(old, [submission('codeforces', {
+ submission_id: 'old-1', problem_id: '1900:A', problem_title: '换号之前做过的题',
+ status: 'AC', submitted_at: 1000, difficulty: 1900,
+ })]);
+
+ const exclude = danExcludedProblems(s.db, s.user);
+ assert.ok(exclude.has('1900:A'), '归档账号上 AC 过的题必须仍在排除集里');
+
+ // 池子里只有这一道,排除后应当抽不到 —— 不抛错,而是明确报「没题了」。
+ assert.throws(
+ () => {
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'advanced', now: 1000 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1900, 1, 1900), now: 1000, dateKey: '2026-10-09' });
+ },
+ (error: unknown) => error instanceof DanError && error.code === 'POOL_EMPTY',
+ '归档账号做过的题不该被抽到',
+ );
+ } finally { s.db.close(); }
+});
+
+test('正在计时的题不会被抽到(计时可以不经提交直接开始)', () => {
+ const s = setup();
+ try {
+ const pool = [{ contestId: 1950, index: 'A', rating: 1700 }, { contestId: 1951, index: 'A', rating: 1700 }];
+ // 直接对题号起计时,不经过任何提交 —— 这是「专注计时」的正常用法。
+ startTimer(s.db, { userId: s.user, problemId: '1950:A', practiceKind: 'unknown', requestId: 'manual-timer' }, 1000);
+ const exclude = danExcludedProblems(s.db, s.user);
+ assert.ok(exclude.has('1950:A'), '正在计时的题必须在排除集里');
+
+ // 池子里剩另一道 1700,所以还能抽出题,但绝不会是手上那道。
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'advanced', now: 1000 });
+ for (let i = 0; i < 200; i += 1) {
+ const drawn = drawNextDanStage(s.db, { sessionId: 's1', pool, now: 1000, dateKey: '2026-10-09' });
+ if (!drawn) break;
+ assert.notEqual(drawn.problem_id, '1950:A', '不该抽到正在计时的那道题');
+ // 清掉这一道好继续抽下一轮:把它标成已结算。
+ s.db.prepare("UPDATE dan_stages SET outcome='cleared' WHERE id=?").run(drawn.id);
+ if (i === 0) break; // 抽到一次就够证明;重复抽会因为排除同轮已抽而变复杂
+ }
+ } finally { s.db.close(); }
+});
+
+test('每日一题可复现:同一天同一用户抽到同一道', () => {
+ const pool = at(1500, 40, 1);
+ const options = { minRating: DAILY_TIER.minRating, maxRating: DAILY_TIER.maxRating, exclude: ANY };
+ const first = danProblemId(dailyDanCandidate(pool, options, 'daily:2026-10-09:1')!);
+ const again = danProblemId(dailyDanCandidate(pool, options, 'daily:2026-10-09:1')!);
+ assert.equal(first, again);
+ const others = ['daily:2026-10-10:1', 'daily:2026-10-09:2', 'daily:2026-10-11:7']
+ .map((seed) => danProblemId(dailyDanCandidate(pool, options, seed)!));
+ assert.ok(others.some((id) => id !== first), '换一天或换个人应当抽到不同的题');
+});
+
+test('danDateKey 按给定时区换算日期', () => {
+ const epoch = Date.parse('2026-10-08T16:30:00Z') / 1000;
+ assert.equal(danDateKey(epoch, 480), '2026-10-09');
+ assert.equal(danDateKey(epoch, 0), '2026-10-08');
+});
+
+test('每日一题难度带:以等效 Rating 为中心 ±200,中心取整到 100,两端裁进 800–2600', () => {
+ // 没有水平数据:退回全段(老行为)。
+ assert.deepEqual(dailyBand(null), { minRating: 800, maxRating: 2600, center: null, equivalentRating: null });
+ // 常规:1543 → 1500 → [1300, 1700]。
+ const band = dailyBand(1543);
+ assert.deepEqual([band.minRating, band.maxRating, band.center], [1300, 1700, 1500]);
+ // 取整到 100 让带子对分数的小幅漂移不敏感:1543 与 1549 同带(都取整到 1500)。
+ assert.deepEqual([dailyBand(1549).minRating, dailyBand(1549).maxRating], [1300, 1700]);
+ // 但跨过 1550 这道坎(四舍五入到 1600)带子就会挪 —— 这是「按水平定带」的代价。
+ assert.deepEqual([dailyBand(1550).minRating, dailyBand(1550).maxRating], [1400, 1800]);
+ // 水平很低:两端都裁到 800,带子收敛成 [800, 800],而不是空集。
+ assert.deepEqual([dailyBand(48).minRating, dailyBand(48).maxRating], [800, 800]);
+ assert.deepEqual([dailyBand(300).minRating, dailyBand(300).maxRating], [800, 800]);
+ // 水平很高:2600 以上是外推段,带子收敛到 [2600, 2600]。
+ assert.deepEqual([dailyBand(2940).minRating, dailyBand(2940).maxRating], [2600, 2600]);
+ // 边界附近不越界。
+ assert.deepEqual([dailyBand(900).minRating, dailyBand(900).maxRating], [800, 1100]);
+ assert.deepEqual([dailyBand(2500).minRating, dailyBand(2500).maxRating], [2300, 2600]);
+});
+
+test('每日一题落在难度带里,且带子换了题也换', () => {
+ // 全段池子(800–2600 各若干道),验证「带」真的约束了抽取。
+ const pool = [...at(800, 10, 1), ...at(1200, 10, 100), ...at(1500, 10, 200), ...at(1900, 10, 300), ...at(2400, 10, 400)];
+ const band = dailyBand(1543); // → [1300, 1700],池子里只有 1500 落在带内。
+ const picked = dailyDanCandidate(pool,
+ { minRating: band.minRating, maxRating: band.maxRating, exclude: ANY }, 'daily:2026-10-09:1')!;
+ assert.equal(picked.rating, 1500);
+ // 换一个中心(2100 → [1900, 2300],带内只有 1900)就抽到别的难度。
+ const higher = dailyBand(2100);
+ const other = dailyDanCandidate(pool,
+ { minRating: higher.minRating, maxRating: higher.maxRating, exclude: ANY }, 'daily:2026-10-09:1')!;
+ assert.equal(other.rating, 1900);
+});
+
+test('标签过滤是「含任一」:多个标签取并集,没选的标签抽不到', () => {
+ const pool = [
+ { contestId: 1, index: 'A', rating: 1200, tags: ['dp'] },
+ { contestId: 2, index: 'A', rating: 1200, tags: ['math'] },
+ { contestId: 3, index: 'A', rating: 1200, tags: ['graphs', 'dp'] },
+ { contestId: 4, index: 'A', rating: 1200, tags: ['greedy'] },
+ ];
+ const options = { minRating: 800, maxRating: 2600, exclude: ANY };
+ // 单标签:只抽带这个标签的。
+ for (let i = 0; i < 200; i += 1) {
+ const picked = drawDanCandidate(pool, { ...options, tags: new Set(['dp']) })!;
+ assert.ok(picked.tags!.includes('dp'), `抽到了不带 dp 的题:${danProblemId(picked)}`);
+ }
+ // 两个标签取并集:dp ∪ math = 1,2,3 号题,4 号(greedy)绝不该出现。
+ const seen = new Set();
+ for (let i = 0; i < 500; i += 1) {
+ const picked = drawDanCandidate(pool, { ...options, tags: new Set(['dp', 'math']) })!;
+ seen.add(danProblemId(picked));
+ assert.notEqual(danProblemId(picked), '4:A');
+ }
+ assert.deepEqual([...seen].sort(), ['1:A', '2:A', '3:A'], '并集应当覆盖三个题号');
+ // 池子里没有任何题带这个标签:如实报「没题」,而不是放开条件。
+ assert.equal(drawDanCandidate(pool, { ...options, tags: new Set(['nonexistent']) }), null);
+ // 老缓存(v1,没有 tags 字段):带标签条件时一律不抽,不把「没有标签信息」当成「没有标签」。
+ const legacy = pool.map(({ contestId, index, rating }) => ({ contestId, index, rating }));
+ assert.equal(drawDanCandidate(legacy, { ...options, tags: new Set(['dp']) }), null);
+ // 不给 tags 字段 = 不筛(所有档位、随机段位、每日一题都走这条)。
+ assert.ok(drawDanCandidate(legacy, options));
+});
+
+test('自定义单题抽题:条件落库、抽题遵守范围与标签、限时随当题难度', () => {
+ const s = setup();
+ try {
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1_000_000, minRating: 1200, maxRating: 1900, tags: ['dp', 'graphs'] });
+ const session = s.db.prepare('SELECT * FROM dan_sessions WHERE id=?').get('s1') as { tier: string; min_rating: number; max_rating: number; tags_json: string };
+ assert.equal(session.tier, 'custom');
+ assert.deepEqual([session.min_rating, session.max_rating], [1200, 1900]);
+ assert.deepEqual(JSON.parse(session.tags_json), ['dp', 'graphs']);
+
+ const pool = [
+ { contestId: 1, index: 'A', rating: 800, tags: ['dp'] }, // 低于下限
+ { contestId: 2, index: 'A', rating: 1200, tags: ['greedy'] }, // 范围内但标签不符
+ { contestId: 3, index: 'A', rating: 1500, tags: ['dp'] }, // ✓
+ { contestId: 4, index: 'A', rating: 1900, tags: ['graphs', 'dp'] }, // ✓
+ { contestId: 5, index: 'A', rating: 2400, tags: ['dp'] }, // 高于上限
+ ];
+ for (let i = 0; i < 300; i += 1) {
+ const drawn = drawNextDanStage(s.db, { sessionId: 's1', pool, now: 1_000_000 + i, dateKey: '2026-10-09' });
+ if (!drawn) break;
+ assert.ok(drawn.difficulty >= 1200 && drawn.difficulty <= 1900, `抽到范围外:${drawn.difficulty}`);
+ assert.ok(['3:A', '4:A'].includes(drawn.problem_id), `抽到标签不符的题:${drawn.problem_id}`);
+ // 限时随当题难度:1500 → 40 分钟、1900 → 50 分钟,而不是 session 回退值 60 分钟。
+ assert.equal(drawn.limit_seconds, danLimitForRating(drawn.difficulty));
+ // 单题抽完就结束,清场再抽(单题模式 stage_count=1,抽下一道前先结算这道)。
+ s.db.prepare("UPDATE dan_stages SET outcome='cleared' WHERE id=?").run(drawn.id);
+ s.db.prepare("UPDATE dan_sessions SET status='active' WHERE id='s1'").run();
+ if (i >= 2) break;
+ }
+ const view = viewOf(s, 's1');
+ assert.equal(view.tierName, '自定义');
+ assert.deepEqual(view.tags, ['dp', 'graphs']);
+ assert.equal(view.perStageLimit, true);
+ } finally { s.db.close(); }
+});
+
+test('自定义抽题的条件校验:只允许单题模式,范围与标签个数有边界', () => {
+ const s = setup();
+ try {
+ // 挑战模式不许自定义:四道连抽的「段位」是固定口径,不跟自定义条件混。
+ assert.throws(() => createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge',
+ tierKey: 'custom', now: 1, minRating: 1200, maxRating: 1900 }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ // 上下限必填。
+ assert.throws(() => createDanSession(s.db, { id: 's2', userId: s.user, kind: 'single', tierKey: 'custom', now: 1 }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ // 范围越界(上界 3500 是题库真实上界)。
+ assert.throws(() => createDanSession(s.db, { id: 's3', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1, minRating: 1200, maxRating: 9900 }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ assert.throws(() => createDanSession(s.db, { id: 's4', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1, minRating: 700, maxRating: 1900 }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ // 下限大于上限。
+ assert.throws(() => createDanSession(s.db, { id: 's5', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1, minRating: 1900, maxRating: 1200 }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ // 标签个数有上限(防把请求撑爆)。
+ assert.throws(() => createDanSession(s.db, { id: 's6', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1, minRating: 1200, maxRating: 1900, tags: Array.from({ length: 9 }, (_, i) => `t${i}`) }),
+ (error: unknown) => error instanceof DanError && error.code === 'CUSTOM_INVALID');
+ // 合法输入照常建轮次。
+ createDanSession(s.db, { id: 's7', userId: s.user, kind: 'single', tierKey: 'custom',
+ now: 1, minRating: 1200, maxRating: 1900, tags: ['dp'] });
+ assert.equal(activeDanSession(s.db, s.user)!.tier, 'custom');
+ } finally { s.db.close(); }
+});
+
+test('API:自定义抽题带标签,题库不认识的标签直接拒', async () => {
+ const s = setup();
+ try {
+ const pool = [
+ { contestId: 1, index: 'A', rating: 1300, tags: ['dp'] },
+ { contestId: 2, index: 'A', rating: 1500, tags: ['dp', 'graphs'] },
+ { contestId: 3, index: 'A', rating: 1700, tags: ['math'] },
+ ];
+ s.db.prepare('INSERT INTO fetch_cache(cache_key,payload,expires_at) VALUES(?,?,unixepoch()+3600)')
+ .run(PROBLEM_RATINGS_CACHE_KEY, JSON.stringify(pool));
+ const ctx = { db: s.db, dbPath: ':memory:', platforms: ['codeforces'], envFile: 'unused',
+ openWrite: () => s.db, syncJobs: { checkTimer: () => {}, timerCheckState: () => null } as never };
+ const call = (method: string, pathname: string, params: Record = {}, body?: unknown) =>
+ handleApi(ctx, { method, pathname, params: new URLSearchParams(params), body });
+
+ // 不认识的标签:400,且不留下半截轮次。
+ const bad = await call('POST', '/api/dan/start', {},
+ { userId: s.user, kind: 'single', tier: 'custom', minRating: 1200, maxRating: 1900, tags: ['dp', 'no-such-tag'] });
+ assert.equal(bad.status, 400);
+ assert.match(String((bad.body as { error: string }).error), /no-such-tag/);
+ assert.equal(activeDanSession(s.db, s.user), null, '被拒的请求不得留下轮次');
+
+ // 合法条件:建轮次、抽题、视图带标签;下发的仍然只有难度。
+ const started = await call('POST', '/api/dan/start', {},
+ { userId: s.user, kind: 'single', tier: 'custom', minRating: 1200, maxRating: 1600, tags: ['dp'] });
+ assert.equal(started.status, 200);
+ const body = started.body as { session: { tier: string; tags: string[]; minRating: number; maxRating: number };
+ stage: { difficulty: number } | null };
+ assert.equal(body.session.tier, 'custom');
+ assert.deepEqual(body.session.tags, ['dp']);
+ assert.ok([1300, 1500].includes(body.stage!.difficulty), '只能抽到 dp 且在 1200–1600 的题');
+ const json = JSON.stringify(body);
+ assert.ok(!json.includes('codeforces.com'), '自定义抽题同样不发链接');
+ assert.ok(!/"problemId":"\d+:/i.test(json), '自定义抽题同样不发题号');
+
+ // 面板:标签清单带题量、按题量降序;难度带字段在。
+ const panel = (await call('GET', '/api/dan', { user: String(s.user), tz: '480' })).body as {
+ tags: { name: string; count: number }[]; dailyBand: { minRating: number; maxRating: number; center: number | null } };
+ assert.deepEqual(panel.tags, [{ name: 'dp', count: 2 }, { name: 'graphs', count: 1 }, { name: 'math', count: 1 }]);
+ assert.deepEqual(panel.dailyBand, { minRating: 800, maxRating: 2600, center: null, equivalentRating: null },
+ '没有可计分成绩时难度带退回全段');
+ } finally { s.db.close(); }
+});
+
+test('API:有计分成绩时,每日一题按等效 Rating 定带', async () => {
+ const s = setup();
+ try {
+ // 两道 1500 分的 AC,用时正好压在 T97(S 档,factor = 1.0):
+ // 单题 rating = 1500/50 = 30.0,等效 Rating = 平均单题 × 50 = 1500。
+ const t97 = Math.round(lookupT97(1500).seconds);
+ for (let i = 0; i < 2; i += 1) {
+ s.repo.saveSubmissions(s.account, [submission('codeforces', {
+ submission_id: `eq-${i}`, problem_id: `90${i}:A`, problem_title: '定带用的题',
+ status: 'AC', submitted_at: 1_700_000 + i, difficulty: 1500,
+ })]);
+ recordPractice(s.db, { userId: s.user, platform: 'codeforces', problemId: `90${i}:A`,
+ seconds: t97, outcome: 'ac', practiceKind: 'first', timingSource: 'manual', manual: true,
+ title: '定带用的题', attemptedAt: 1_700_000 + i });
+ }
+ const entries = listDxEntries(s.db, s.user, 'codeforces');
+ assert.equal(entries.length, 2, '练习记录要能被 listDxEntries 读到');
+ assert.ok(entries.every((entry) => entry.recordedSeconds !== null), '用时要被读到');
+
+ // 池子只在带内 [1300,1700] 放题:带子生效的话只能抽到这两档。
+ const pool = [...at(1300, 5, 1), ...at(1700, 5, 100), ...at(2400, 5, 200)];
+ s.db.prepare('INSERT INTO fetch_cache(cache_key,payload,expires_at) VALUES(?,?,unixepoch()+3600)')
+ .run(PROBLEM_RATINGS_CACHE_KEY, JSON.stringify(pool));
+ const ctx = { db: s.db, dbPath: ':memory:', platforms: ['codeforces'], envFile: 'unused',
+ openWrite: () => s.db, syncJobs: { checkTimer: () => {}, timerCheckState: () => null } as never };
+ const panel = (await handleApi(ctx, { method: 'GET', pathname: '/api/dan',
+ params: new URLSearchParams({ user: String(s.user), tz: '480' }), body: undefined })).body as {
+ dailyBand: { minRating: number; maxRating: number; center: number | null; equivalentRating: number };
+ daily: { difficulty: number } | null };
+ // 等效 1500 → 带 [1300, 1700]。
+ assert.equal(panel.dailyBand.center, 1500);
+ assert.deepEqual([panel.dailyBand.minRating, panel.dailyBand.maxRating], [1300, 1700]);
+ assert.ok([1300, 1700].includes(panel.daily!.difficulty), '每日一题必须落在带内');
+ } finally { s.db.close(); }
+});
+
+test('口径 A:「开始做题」之前,下发数据里没有题号、题名与链接', () => {
+ const s = setup();
+ try {
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'advanced', now: 1_000_000 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1800, 20, 1), now: 1_000_000, dateKey: '2026-10-09' });
+ const stage = danSessionView(s.db, activeDanSession(s.db, s.user)!).stages[0];
+ assert.equal(stage.difficulty, 1800, '难度分数是允许下发的');
+ assert.equal(stage.problemId, null);
+ assert.equal(stage.problemUrl, null);
+ assert.equal(stage.claimed, false);
+ const json = JSON.stringify(danSessionView(s.db, activeDanSession(s.db, s.user)!));
+ assert.ok(!json.includes('codeforces.com'), `下发数据里出现了链接:${json}`);
+ assert.ok(!/\d{1,7}:[A-Za-z]\d*/.test(json), `下发数据里出现了题号:${json}`);
+ } finally { s.db.close(); }
+});
+
+test('口径 A:claim 之后才下发题号与链接', () => {
+ const s = setup();
+ try {
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'beginner', now: 1_000_000 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1000, 5, 1), now: 1_000_000, dateKey: '2026-10-09' });
+ const drawn = beginStage(s, 's1', 1_000_000, 'dan-timer-a1');
+ const stage = danSessionView(s.db, activeDanSession(s.db, s.user)!).stages[0];
+ assert.equal(stage.claimed, true);
+ assert.equal(stage.problemId, drawn.problem_id);
+ assert.equal(stage.problemUrl, `https://codeforces.com/contest/${drawn.problem_id.split(':')[0]}/problem/A`);
+ assert.equal(stage.deadlineAt, 1_000_000 + 1800, '限时从 claim 起算');
+ } finally { s.db.close(); }
+});
+
+test('段位認定四道全部通关才算通过,总分是各道的单题 rating 之和', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'advanced', now: t0 });
+ const pool = at(1800, 30, 1);
+ let total = 0;
+ const drawnIds: string[] = [];
+ for (let i = 1; i <= DAN_STAGES; i += 1) {
+ drawNextDanStage(s.db, { sessionId: 's1', pool, now: t0, dateKey: '2026-10-09' });
+ const stage = playStage(s, 's1', t0 + i * 10000, 600 + i * 60, String(i));
+ drawnIds.push(stage.problem_id);
+ const row = viewOf(s, 's1').stages.find((entry) => entry.index === i)!;
+ assert.equal(row.outcome, 'cleared');
+ assert.equal(row.seconds, 600 + i * 60);
+ assert.ok(row.rating! > 0);
+ total += row.rating!;
+ }
+ assert.equal(new Set(drawnIds).size, DAN_STAGES, '同一轮内不应抽到重复的题');
+
+ const session = danHistory(s.db, s.user)[0];
+ assert.equal(session.status, 'cleared');
+ assert.equal(session.stages.length, DAN_STAGES);
+ assert.equal(session.totalRating, Math.round(total * 10) / 10);
+ assert.ok(session.finishedAt);
+ for (const stage of session.stages) assert.ok(stage.problemId, '结算后记录里应当能看到题号');
+
+ // 规则随本轮冻结,之后调档位/限时不会改写旧记录。
+ const settlement = JSON.parse((s.db.prepare('SELECT settlement_json FROM dan_sessions WHERE id=?').get('s1') as { settlement_json: string }).settlement_json);
+ assert.equal(settlement.stageCount, DAN_STAGES);
+ assert.equal(settlement.limitSeconds, 3000);
+ assert.equal(settlement.totalRating, session.totalRating);
+ assert.equal(settlement.stages.length, DAN_STAGES);
+ // 逐题限时也进快照 —— 随机段位每题不同,历史要能按当时的规则解释。
+ for (const stage of settlement.stages) assert.equal(stage.limitSeconds, 3000, '上级档统一 3000 秒');
+ assert.equal(settlement.selfReported, true, '必须标明这是自测,不是防作弊');
+ } finally { s.db.close(); }
+});
+
+test('单题超时:那一轮判为失败并结束,进行中的计时器被释放', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'beginner', now: t0 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1000, 5, 1), now: t0, dateKey: '2026-10-09' });
+ beginStage(s, 's1', t0, 'dan-timer-t1');
+
+ advanceDanSessions(s.db, t0 + 1799);
+ assert.equal(activeDanSession(s.db, s.user)!.status, 'active', '限时内不应判负');
+
+ advanceDanSessions(s.db, t0 + 1801);
+ assert.equal(activeDanSession(s.db, s.user), null);
+ const session = danHistory(s.db, s.user)[0];
+ assert.equal(session.status, 'failed');
+ assert.equal(session.stages[0].outcome, 'timeout');
+ assert.equal(session.totalRating, null);
+ const running = s.db.prepare("SELECT COUNT(*) AS n FROM practice_timers WHERE status='running'").get() as { n: number };
+ assert.equal(running.n, 0, '超时必须释放计时器,否则用户再也起不了新的计时');
+ } finally { s.db.close(); }
+});
+
+test('放弃一轮:判为 abandoned,进行中的计时器被释放', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'expert', now: t0 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(2300, 5, 1), now: t0, dateKey: '2026-10-09' });
+ beginStage(s, 's1', t0, 'dan-timer-x1');
+ abandonDanSession(s.db, { sessionId: 's1', userId: s.user, now: t0 + 60 });
+ assert.equal(activeDanSession(s.db, s.user), null);
+ const session = danHistory(s.db, s.user)[0];
+ assert.equal(session.status, 'abandoned');
+ assert.equal(session.stages[0].outcome, 'interrupted');
+ const running = s.db.prepare("SELECT COUNT(*) AS n FROM practice_timers WHERE status='running'").get() as { n: number };
+ assert.equal(running.n, 0);
+ } finally { s.db.close(); }
+});
+
+test('抽了不点也会到期作废,不会把唯一的活动轮次永久占住', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'beginner', now: t0 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1000, 5, 1), now: t0, dateKey: '2026-10-09' });
+ advanceDanSessions(s.db, t0 + DAN_SESSION_TTL_SECONDS - 1);
+ assert.ok(activeDanSession(s.db, s.user), '未到期不应作废');
+ advanceDanSessions(s.db, t0 + DAN_SESSION_TTL_SECONDS + 1);
+ assert.equal(activeDanSession(s.db, s.user), null);
+ assert.equal(danHistory(s.db, s.user)[0].status, 'abandoned');
+ } finally { s.db.close(); }
+});
+
+test('一个用户同时只能有一轮进行中的认定(应用层与数据库层各一道)', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'challenge', tierKey: 'advanced', now: t0 });
+ assert.throws(
+ () => createDanSession(s.db, { id: 's2', userId: s.user, kind: 'single', tierKey: 'beginner', now: t0 }),
+ (error: unknown) => error instanceof DanError && error.code === 'SESSION_ACTIVE',
+ );
+ // 绕开应用层直接插第二条,唯一索引也要拦住。
+ assert.throws(() => s.db.prepare(`INSERT INTO dan_sessions
+ (id,user_id,tier,kind,stage_count,limit_seconds,min_rating,max_rating,status,started_at)
+ VALUES('s3',?,'beginner','single',1,1800,800,1100,'active',?)`).run(s.user, t0));
+ } finally { s.db.close(); }
+});
+
+test('区间里没有没做过的题时明确报错,不静默抽区间外的题', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'beginner', now: t0 });
+ assert.throws(
+ () => drawNextDanStage(s.db, { sessionId: 's1', pool: [], now: t0, dateKey: '2026-10-09' }),
+ (error: unknown) => error instanceof DanError && error.code === 'POOL_EMPTY',
+ );
+ // 唯一的候选已经被做过 → 同样报错。
+ s.repo.saveSubmissions(s.account, [submission('codeforces', {
+ submission_id: 'dan-seen-1', problem_id: '1:A', problem_title: '做过',
+ status: 'AC', submitted_at: 100, difficulty: 1000,
+ })]);
+ assert.throws(
+ () => drawNextDanStage(s.db, { sessionId: 's1', pool: at(1000, 1, 1), now: t0, dateKey: '2026-10-09' }),
+ (error: unknown) => error instanceof DanError && error.code === 'POOL_EMPTY',
+ );
+ } finally { s.db.close(); }
+});
+
+test('每日一题的 session 抽到的就是当天的确定性结果', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000, pool = at(1500, 40, 1);
+ createDanSession(s.db, { id: 'd1', userId: s.user, kind: 'daily', tierKey: 'daily', now: t0 });
+ const session = activeDanSession(s.db, s.user)!;
+ assert.equal(session.tier, DAILY_TIER.key);
+ assert.equal(session.stage_count, 1);
+ const drawn = drawNextDanStage(s.db, { sessionId: 'd1', pool, now: t0, dateKey: '2026-10-09' })!;
+ const expected = dailyDanCandidate(pool,
+ { minRating: DAILY_TIER.minRating, maxRating: DAILY_TIER.maxRating, exclude: ANY }, `daily:2026-10-09:${s.user}`)!;
+ assert.equal(drawn.problem_id, danProblemId(expected));
+ } finally { s.db.close(); }
+});
+
+test('不认识的档位直接报错,不会抽到区间外的题', () => {
+ const s = setup();
+ try {
+ assert.throws(
+ () => createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'mythic', now: 1_000_000 }),
+ (error: unknown) => error instanceof DanError && error.code === 'TIER_UNKNOWN',
+ );
+ // daily 是内部模式,不能被当成可选档位传进来。
+ assert.throws(
+ () => createDanSession(s.db, { id: 's2', userId: s.user, kind: 'single', tierKey: 'daily', now: 1_000_000 }),
+ (error: unknown) => error instanceof DanError && error.code === 'TIER_UNKNOWN',
+ );
+ } finally { s.db.close(); }
+});
+
+test('挑战的成绩同时进入普通练习记录与 B50 计分(段位認定不是另一套计分)', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'advanced', now: t0 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1800, 5, 1), now: t0, dateKey: '2026-10-09' });
+ const stage = playStage(s, 's1', t0, 900, 'p1');
+
+ // `timing_source` 存进库的只有 legacy/manual/contest_estimate;'timer' 是
+ // listPractice 按 practice_timers.attempt_id 反查出来的派生值(practice.ts:154)。
+ const attempt = listPractice(s.db, s.user).find((row) => row.problem_id === stage.problem_id)!;
+ assert.equal(attempt.seconds, 900);
+ assert.equal(attempt.outcome, 'ac');
+ assert.equal(attempt.timing_source, 'timer');
+
+ // 同一个用时进入 B50 的原料,且用同一个 scoreProblem 得到同一个分数。
+ const entry = listDxEntries(s.db, s.user, 'codeforces').find((row) => row.problemId === stage.problem_id)!;
+ assert.equal(entry.recordedSeconds, 900);
+ assert.equal(scoreProblem(entry).rating, viewOf(s, 's1').stages[0].rating);
+
+ // 结算页要用的字段:B50 对比(来自 practice_timers.settlement_json,不是另算一套)、
+ // 判定计数、题目名 —— 这样单题结算才能和普通计时结算显示同一个 DX RATING 与增量。
+ const view = viewOf(s, 's1').stages[0]!;
+ const comparison = view.comparison!;
+ assert.ok(comparison, '结算后必须带上 reconcileTimers 冻结的 B50 对比');
+ assert.equal(comparison.ratingDelta, Math.round((comparison.ratingAfter - comparison.ratingBefore) * 10) / 10);
+ assert.equal(view.verdicts.AC, 1);
+ assert.equal(view.waCount, 0);
+ assert.equal(view.practiceKind, 'unknown');
+ assert.ok(view.title, '结算卡要显示题目名');
+ } finally { s.db.close(); }
+});
+
+test('未开始的那道不下发题目名,也不带结算数据(口径 A 覆盖到新字段)', () => {
+ const s = setup();
+ try {
+ const t0 = 1_000_000;
+ createDanSession(s.db, { id: 's1', userId: s.user, kind: 'single', tierKey: 'advanced', now: t0 });
+ drawNextDanStage(s.db, { sessionId: 's1', pool: at(1800, 5, 1), now: t0, dateKey: '2026-10-09' });
+ const stage = viewOf(s, 's1').stages[0]!;
+ assert.equal(stage.problemId, null);
+ assert.equal(stage.problemUrl, null);
+ assert.equal(stage.title, null);
+ assert.equal(stage.comparison, null);
+ assert.deepEqual(stage.verdicts, {});
+ } finally { s.db.close(); }
+});
+
+test('五个抽题接口都登记在 WRITE_ROUTES 里(写端点清单是安全边界的一部分)', () => {
+ for (const path of ['/api/dan/start', '/api/dan/claim', '/api/dan/settle', '/api/dan/next', '/api/dan/abandon']) {
+ assert.ok(WRITE_ROUTES.has(path), `${path} 必须登记为写端点`);
+ }
+ assert.ok(!WRITE_ROUTES.has('/api/dan'), 'GET /api/dan 是只读端点,不应登记为写端点');
+});
+
+test('API:settle 只结算不抽题,抽下一道必须由 next 触发(结算页才停得住)', async () => {
+ const s = setup();
+ try {
+ s.db.prepare('INSERT INTO fetch_cache(cache_key,payload,expires_at) VALUES(?,?,unixepoch()+3600)')
+ .run(PROBLEM_RATINGS_CACHE_KEY, JSON.stringify(at(1800, 40, 1)));
+ const ctx = { db: s.db, dbPath: ':memory:', platforms: ['codeforces'], envFile: 'unused',
+ openWrite: () => s.db, syncJobs: { checkTimer: () => {}, timerCheckState: () => null } as never };
+ const call = (method: string, pathname: string, params: Record = {}, body?: unknown) =>
+ handleApi(ctx, { method, pathname, params: new URLSearchParams(params), body });
+
+ const started = await call('POST', '/api/dan/start', {}, { userId: s.user, kind: 'challenge', tier: 'advanced', tz: 480 });
+ const sessionId = (started.body as { session: { id: string } }).session.id;
+ const claimed = (await call('POST', '/api/dan/claim', {}, { userId: s.user, sessionId })).body as
+ { problemId: string; timerId: string; serverNow: number; difficulty: number };
+
+ // 计时器往前挪 600 秒,并在「现在」交掉这道题(reconcileTimers 要求 submitted_at 严格晚于起点、且不晚于 now)。
+ s.db.prepare('UPDATE practice_timers SET started_at = started_at - 600 WHERE id = ?').run(claimed.timerId);
+ s.repo.saveSubmissions(s.account, [submission('codeforces', {
+ submission_id: 'settle-sub-1', problem_id: claimed.problemId, problem_title: '段位認定',
+ status: 'AC', submitted_at: claimed.serverNow, difficulty: claimed.difficulty,
+ })]);
+
+ // settle:出成绩、就停在结算上,不抽下一道。
+ const settled = (await call('POST', '/api/dan/settle', {}, { userId: s.user, tz: 480 })).body as
+ { session: { stages: { outcome: string | null; seconds: number | null; claimed: boolean }[] } };
+ assert.equal(settled.session.stages.length, 1, 'settle 不得顺手把下一道抽出来');
+ assert.equal(settled.session.stages[0].outcome, 'cleared');
+ assert.equal(settled.session.stages[0].seconds, 600);
+
+ // next:用户点了「抽选下一题」才抽,而且新抽的那道依旧不下发题号。
+ const next = (await call('POST', '/api/dan/next', {}, { userId: s.user, tz: 480 })).body as
+ { session: { stages: { claimed: boolean; problemId: string | null }[] } };
+ assert.equal(next.session.stages.length, 2, 'next 才抽下一道');
+ assert.equal(next.session.stages[1].claimed, false);
+ assert.equal(next.session.stages[1].problemId, null);
+ } finally { s.db.close(); }
+});
+
+test('API:抽题前不下发链接,claim 才下发,重复 claim 幂等', async () => {
+ const s = setup();
+ try {
+ // 预置题库缓存:这样 fetch_problem_ratings 直接命中缓存,测试不打网络。
+ s.db.prepare('INSERT INTO fetch_cache(cache_key,payload,expires_at) VALUES(?,?,unixepoch()+3600)')
+ .run(PROBLEM_RATINGS_CACHE_KEY, JSON.stringify(at(1800, 40, 1)));
+ const ctx = { db: s.db, dbPath: ':memory:', platforms: ['codeforces'], envFile: 'unused',
+ openWrite: () => s.db, syncJobs: { checkTimer: () => {}, timerCheckState: () => null } as never };
+ const call = (method: string, pathname: string, params: Record = {}, body?: unknown) =>
+ handleApi(ctx, { method, pathname, params: new URLSearchParams(params), body });
+
+ // 1) 面板:题库就绪;每日一题只给难度
+ const panel = (await call('GET', '/api/dan', { user: String(s.user), tz: '480' })).body as Record;
+ assert.equal(panel.poolReady, true);
+ assert.equal(panel.poolSize, 40);
+ assert.equal(typeof (panel.daily as { difficulty: number }).difficulty, 'number');
+ assert.ok(!JSON.stringify(panel).includes('codeforces.com'), '面板不得下发链接');
+
+ // 2) 抽题:只给难度
+ const started = await call('POST', '/api/dan/start', {}, { userId: s.user, kind: 'challenge', tier: 'advanced', tz: 480 });
+ assert.equal(started.status, 200);
+ const startBody = started.body as { session: { id: string; stages: { problemId: null }[] }; stage: { index: number; difficulty: number } };
+ assert.equal(startBody.stage.index, 1);
+ assert.equal(startBody.stage.difficulty, 1800);
+ assert.equal(startBody.session.stages[0].problemId, null);
+ assert.ok(!JSON.stringify(started.body).includes('codeforces.com'), '抽题响应不得含链接');
+
+ // 3) 开始做题:链接唯一的出口
+ const sessionId = startBody.session.id;
+ const claim = await call('POST', '/api/dan/claim', {}, { userId: s.user, sessionId });
+ assert.equal(claim.status, 200);
+ const claimBody = claim.body as { url: string; difficulty: number; deadlineAt: number; serverNow: number; timerId: string };
+ assert.match(claimBody.url, /^https:\/\/codeforces\.com\/contest\/\d+\/problem\/[A-Z]\d*$/);
+ assert.equal(claimBody.difficulty, 1800);
+ assert.equal(claimBody.deadlineAt, claimBody.serverNow + 3000, '上级档限时 3000 秒');
+
+ // 4) 重复点击幂等:同一条计时、同一个链接,不会起第二个计时器
+ const again = await call('POST', '/api/dan/claim', {}, { userId: s.user, sessionId });
+ assert.equal(again.status, 200);
+ assert.equal((again.body as { url: string }).url, claimBody.url);
+ assert.equal((again.body as { timerId: string }).timerId, claimBody.timerId);
+ const timers = s.db.prepare('SELECT COUNT(*) AS n FROM practice_timers').get() as { n: number };
+ assert.equal(timers.n, 1);
+
+ // 5) 上一轮没结束就开新一轮:状态冲突(409),前端据此提示先完成或放弃
+ const conflict = await call('POST', '/api/dan/start', {}, { userId: s.user, kind: 'single', tier: 'beginner' });
+ assert.equal(conflict.status, 409);
+ assert.equal((conflict.body as { code: string }).code, 'SESSION_ACTIVE');
+
+ // 6) 放弃
+ const abandoned = await call('POST', '/api/dan/abandon', {}, { userId: s.user, sessionId });
+ assert.equal(abandoned.status, 200);
+ assert.equal((abandoned.body as { abandoned: boolean }).abandoned, true);
+ assert.equal((s.db.prepare("SELECT COUNT(*) AS n FROM practice_timers WHERE status='running'").get() as { n: number }).n, 0);
+ } finally { s.db.close(); }
+});
+
+test('API:claim 之后视图才揭示题号与链接(刷新页面不会提前泄露)', async () => {
+ const s = setup();
+ try {
+ s.db.prepare('INSERT INTO fetch_cache(cache_key,payload,expires_at) VALUES(?,?,unixepoch()+3600)')
+ .run(PROBLEM_RATINGS_CACHE_KEY, JSON.stringify(at(1000, 20, 1)));
+ const ctx = { db: s.db, dbPath: ':memory:', platforms: ['codeforces'], envFile: 'unused',
+ openWrite: () => s.db, syncJobs: { checkTimer: () => {}, timerCheckState: () => null } as never };
+ const call = (method: string, pathname: string, params: Record = {}, body?: unknown) =>
+ handleApi(ctx, { method, pathname, params: new URLSearchParams(params), body });
+
+ await call('POST', '/api/dan/start', {}, { userId: s.user, kind: 'single', tier: 'beginner', tz: 480 });
+ let panel = (await call('GET', '/api/dan', { user: String(s.user), tz: '480' })).body as { active: { id: string; stages: { problemId: null }[] } };
+ assert.equal(panel.active.stages[0].problemId, null);
+
+ await call('POST', '/api/dan/claim', {}, { userId: s.user, sessionId: panel.active.id });
+ panel = (await call('GET', '/api/dan', { user: String(s.user), tz: '480' })).body as typeof panel;
+ assert.ok(panel.active.stages[0].problemId, 'claim 之后才揭示题号');
+ } finally { s.db.close(); }
+});
\ No newline at end of file