From 8b36a653f4a203bd846dca0bd2951f2ed3a96954 Mon Sep 17 00:00:00 2001
From: Swan416ya <3447049424@qq.com>
Date: Fri, 9 Oct 2026 12:02:44 +0800
Subject: [PATCH 1/7] feat: add random problem draw and dan challenge mode
Adds a "random draw" page with four difficulty tiers (800-1100 / 1200-1500
/ 1600-2000 / 2100-2600) and per-tier time limits, modelled on maimai DX's
random dan certification. The server draws the problem and only hands out
its difficulty: the problem id and URL are not part of any response until
the user clicks "start", so refreshing, right-clicking or opening DevTools
cannot leak the link early. Claiming starts the practice timer and returns
the URL in the same request, which the page follows immediately.
Challenge mode draws three problems in a row, each with its own limit. A
timeout fails and ends the run. No dan name is awarded -- in maimai DX
names come from the fixed-song dan, not the random one -- so the settlement
shows per-problem results and a total only.
Draws are uniform over rating values rather than problems, because the
800-899 bucket alone holds 1104 problems while 900-999 holds 354; drawing
uniformly over problems would make the beginner tier almost entirely 800s.
Already-submitted problems, including WA-only ones, are excluded. Challenge
solves reuse the normal timer path, so they count towards B50 and DX Rating
like any other timed practice.
Schema v18 adds dan_sessions and dan_stages. The dashboard refuses to
migrate on startup by design, so an existing database needs one
`npm run db:init` (or `npm run sync`) after updating.
---
README.md | 2 +
docs/dan.md | 128 ++++++++
docs/development.md | 7 +
public/circle.html | 1 +
public/dan.css | 102 ++++++
public/dan.html | 59 ++++
public/dan.js | 353 ++++++++++++++++++++
public/dx.html | 1 +
public/help.html | 9 +-
public/index.html | 1 +
public/stats.html | 1 +
src/db/database.ts | 1 +
src/db/migrations/018-dan.sql | 51 +++
src/dx/dan.ts | 555 ++++++++++++++++++++++++++++++++
src/dx/timer.ts | 5 +
src/fetchers/codeforces-sync.ts | 19 +-
src/server/api.ts | 168 ++++++++++
tests/dan.test.ts | 442 +++++++++++++++++++++++++
18 files changed, 1903 insertions(+), 2 deletions(-)
create mode 100644 docs/dan.md
create mode 100644 public/dan.css
create mode 100644 public/dan.html
create mode 100644 public/dan.js
create mode 100644 src/db/migrations/018-dan.sql
create mode 100644 src/dx/dan.ts
create mode 100644 tests/dan.test.ts
diff --git a/README.md b/README.md
index 92aec22..720904d 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
- **多平台记录**:集中查看 AC 记录、题目与逐次练习历史。
- **练习数据**:年度热力图、今日时间线、提交趋势和题目分布。
- **题友圈**:关注题友,查看他们在不同平台的做题动态。
+- **随机抽题与段位認定**:按档位抽题(初级/中级/上级/超上级),只看得到难度;点「开始做题」才揭题并起计时。也可以连做 3 道走一轮挑战。
- **本地优先**:数据保存在自己的电脑;零第三方运行依赖,无需构建。
## 功能预览
@@ -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..56ac9d1
--- /dev/null
+++ b/docs/dan.md
@@ -0,0 +1,128 @@
+# 随机抽题与段位認定
+
+「随机抽题」把选题的权力从用户手里拿走:服务端抽一道,一开始只告诉你**难度**,点「开始做题」才揭题并起计时。照舞萌 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 桶里有多少题无关。
+
+### 排除做过的题
+
+抽题前会剔除本机已经同步到的提交记录里出现过的题 —— 包括**只有 WA、没有 AC** 的。依据是本机数据库里的提交记录,所以同步不完整时可能仍有漏网的,这一点在页面上也写明了。
+
+### 每日一题可复现
+
+每日一题用 `(日期, 用户)` 做种子确定性抽取:同一天怎么刷新都是同一道,换一天才换题。随机抽题与挑战模式用 `crypto.randomInt`,服务端抽、服务端存。
+
+## 口径 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`。
+
+## 挑战模式(段位認定)
+
+一轮挑战是 3 道题、逐题限时:
+
+```
+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 之类)—— 只有每题自己的达成率与评级。
+
+### 路径失败也算失败
+
+- 某题超时 → 本轮 `failed`。
+- 结算时该题按实际用时正常计分(超时那一刻计时器被释放,时间是真实的),但**本轮**已经判为失败。
+
+## 成绩的去向
+
+挑战与随机抽题的成绩**不是另一套计分**:走的就是正常的计时练习那条链。
+
+- 起计时 → `practice_timers`;AC 被同步到 → 生成 `practice` 记录(`timing_source` 在库里存 `manual`,读取时因为存在对应计时器而被 `listPractice` 报成 `timer`)。
+- 因此这些记录**正常进入 B50 与 DX Rating**,可以在 [DX Rating](dx-rating.md) 页面看到。
+
+## 自测,不是防作弊
+
+开始做题之后,你照样可以看题解、问 AI、查博客 —— 这一点拦不住。它和「辅助解题」自报标签是同一条现实:**本地优先的工具只能记录你的自述,不能证明你的清白。**
+
+这个功能能保证的只有一件事:**你没法挑题。** 抽到什么做什么。
+
+另外,「排除做过的题」依赖本机已同步的提交记录;同步不完整时可能抽到你已经做过的题(包括已经 AC 过的)。
+
+## 数据
+
+迁移 `018-dan.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`)。
+- `dan_stages`:一道题(`session_id`、`stage_index`、`problem_id`、`difficulty`、`drawn_at`、`claimed_at`、`timer_id`、`outcome` ∈ `cleared` / `timeout` / `interrupted`、`seconds`、`score_json`)。
+
+两个设计点:
+
+- **难度在抽题时快照进 `dan_stages.difficulty`**,结算时不重新查题库。否则等题库缓存过期或题目 rating 被 CF 修订,历史记录会跟着变。
+- **`settlement_json` 在结束时冻结**整轮结果,记录页读的是快照,不是重新算一遍。
+
+## API
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/api/dan?user=&tz=` | 抽题面板与记录页。档位表、题库是否就绪、每日一题的**难度**、进行中的一轮、历史记录 |
+| POST | `/api/dan/start` | 开一轮:`{userId, kind, tier?, tz?}`。kind 为 `daily` 时忽略 tier |
+| POST | `/api/dan/claim` | **唯一一次下发题号与链接**:`{userId, sessionId}`。幂等,重复点击返回同一道题 |
+| 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:v1`),**不新增数据源、不额外请求**。实测全量 11433 道题,其中 11152 道(97.5%)有官方 rating,上限 3500,不含 gym。各档位可用题量:初级 2312、中级 1875、上级 2578、超上级 2576。
+
+缓存过期时 `POST` 那几条路径会触发一次抓取(约 1.5 MB),之后 6 小时内复用。
\ No newline at end of file
diff --git a/docs/development.md b/docs/development.md
index 811ddb4..5eb42ca 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):随机抽题与段位認定。一轮认定一行(档位、`kind` ∈ `challenge`/`single`/`daily`、题数、单题限时、rating 区间、状态),每道题一行(**抽题时快照下来的难度**、`claimed_at`、计时器 id、结果)。难度快照进 `dan_stages` 而不是结算时回查题库,否则题库缓存过期或 CF 修订题目 rating 会让历史记录跟着变;`settlement_json` 同样在结束时冻结。一个用户同时只能有一轮 `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,11 @@ 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/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..75e60e0
--- /dev/null
+++ b/public/dan.css
@@ -0,0 +1,102 @@
+/*
+ * 随机抽题 / 每日一题 / 段位認定。
+ *
+ * 只写这一页特有的布局:按钮、输入、顶栏、卡片底色一律复用 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-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); }
+
+@media (max-width: 780px) { .dan-grid { grid-template-columns: 1fr; } }
\ No newline at end of file
diff --git a/public/dan.html b/public/dan.html
new file mode 100644
index 0000000..54c5c39
--- /dev/null
+++ b/public/dan.html
@@ -0,0 +1,59 @@
+
+
+
随机抽题 · Algorithm DX
+
+
+
+
+
+ DRAW, DON'T PICK
+ 抽一道,别挑。
+ 题目由服务端抽、结果只存在服务端。在你点「开始做题」之前,这道题的题号与链接根本不在这个页面里 —— 刷新、右键、F12 都提前看不到,因为它还不存在。你只看得到难度。点下去的那一刻同时起计时并跳转 Codeforces。
+
+
+
+
+
+
+
+ 从四个档位的并集里按当天日期确定性抽取:同一天怎么刷新都是同一道。
+
+
+
+
+
+ 按 rating 均匀抽取,不是按题目均匀 —— 否则「初级」会几乎全是 800 分。抽完只显示难度。
+
+
+
+
+ 做完一道立刻抽下一道,三道全部在限时内通关才算通过。这是挑战模式,不发段位名 —— 发段位名的是固定选曲段,随机段位認定本来就不给。
+
+
+
+
+
+
+
+
+口径:这是自测,不是防作弊。开始做题之后你照样可以查题解、问 AI —— 这一点拦不住,和「辅助解题」自报标签是同一条现实。它唯一能保证的是你没法挑题。
另外,抽到的题如果正好是你已经提交过的(含只有 WA 的),会被自动排除;排除依据是本机已同步的提交记录,同步不完整时可能仍有漏网。
+
+
+
\ No newline at end of file
diff --git a/public/dan.js b/public/dan.js
new file mode 100644
index 0000000..058f028
--- /dev/null
+++ b/public/dan.js
@@ -0,0 +1,353 @@
+import { startAutoSync } from './auto-sync.js';
+
+/*
+ * 随机抽题 / 每日一题 / 段位認定 的页面逻辑。
+ *
+ * 这一页刻意**不缓存、不预取**任何题目信息:服务端在下发时就不给未开始的题号与链接
+ * (见 src/dx/dan.ts 的口径 A),所以这里也没有任何「提前藏起来」的东西可以做 ——
+ * 唯一的入口是 beginStage(),它在 claim 成功之后才拿到 url 并立刻跳转。
+ */
+
+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 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;
+
+const el = (tag, cls, text) => {
+ const node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+
+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} 秒`);
+
+function difficultyNode(value, caption) {
+ const wrap = el('div', 'dan-difficulty');
+ wrap.append(document.createTextNode(String(value)), el('small', '', caption));
+ return wrap;
+}
+
+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;
+ // 刚结束的那一轮:把结果说出来,而不是让进行中的面板静默消失。
+ if (lastActiveId && !board.active) {
+ const finished = (board.history ?? []).find((run) => run.id === lastActiveId);
+ if (finished) setMessage(`本轮已结束:${STATUS_LABEL[finished.status] ?? finished.status}${finished.totalRating === null ? '' : ` · 总分 ${finished.totalRating.toFixed(1)}`}`, finished.status !== 'cleared');
+ lastActiveId = null;
+ } else if (board.active) {
+ lastActiveId = board.active.id;
+ }
+ renderIdle();
+ renderActive();
+ renderHistory();
+ if (!board.poolReady) setMessage('本地还没有题库缓存。点任意一个档位会自动抓一次全量题目(约 1.5 MB,之后 6 小时内复用)。');
+ schedule();
+}
+
+function tierButton(tier, kind) {
+ const button = el('button', 'dan-tier');
+ button.type = 'button';
+ button.append(el('strong', '', tier.name));
+ button.append(el('small', '', `${tier.minRating}–${tier.maxRating} · ${kind === 'challenge' ? `每道限时 ${fmtLimit(tier.limitSeconds)}` : `限时 ${fmtLimit(tier.limitSeconds)}`}`));
+ 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 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')));
+}
+
+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', () => advance());
+ 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', `限时 ${fmtLimit(session.limitSeconds)} / 道`));
+ head.append(el('span', 'dan-chip', `${session.minRating}–${session.maxRating}`));
+ host.append(head);
+ host.append(progressBar(session));
+
+ const current = session.stages.find((stage) => !stage.outcome);
+ if (current) host.append(stageCard(session, current));
+
+ for (const stage of session.stages.filter((row) => row.outcome)) {
+ const line = el('p', 'dan-note', `第 ${stage.index} 道 ${stage.difficulty} 分 · ${OUTCOME_LABEL[stage.outcome] ?? stage.outcome}`
+ + (stage.rank ? ` · ${stage.rank} ${stage.achievement?.toFixed(2) ?? ''}%` : '')
+ + (stage.rating !== null ? ` · ${stage.rating.toFixed(1)} 分` : ''));
+ host.append(line);
+ }
+
+ 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 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.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);
+ return card;
+}
+
+async function startRun(kind, tier) {
+ if (busy) return;
+ busy = true;
+ setMessage(kind === 'daily' ? '正在抽今天的题…' : '正在抽题…');
+ render();
+ try {
+ await post('/api/dan/start', { userId, kind, tier, tz });
+ 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 advance() {
+ if (busy) return;
+ busy = true;
+ try {
+ const data = await post('/api/dan/next', { userId, tz });
+ clockOffset = data.serverNow - Date.now() / 1000;
+ if (data.poolEmpty) setMessage('这个档位已经没有你没做过的题了,换一个档位吧。', true);
+ await refresh();
+ } catch (error) {
+ setMessage(error.message, true);
+ } finally {
+ busy = false;
+ render();
+ }
+}
+
+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();
+ }
+}
+
+/** 做了题就轮询:等后台同步发现 CF 上的 AC,服务端结算后自动抽下一道。没在计时就不轮询。 */
+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/next', { userId, tz });
+ clockOffset = data.serverNow - Date.now() / 1000;
+ if (data.poolEmpty) setMessage('这个档位已经没有你没做过的题了,换一个档位吧。', true);
+ 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'));
+$('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..8d86dab 100644
--- a/public/help.html
+++ b/public/help.html
@@ -11,13 +11,14 @@
AC 记录
题友圈
DX Rating
+ 随机抽题
练习数据
账号管理
使用指南
GETTING STARTED使用指南
从绑定第一个账号,到记录练习、看懂成绩与统计。
前往账号管理 ↗
-
+
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,12 @@ 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 分钟)。「每日一题」不分档位,从这四档的并集里抽,限时 45 分钟,同一天怎么刷新都是同一道。
+点「开始做题」之前,这道题的题号和链接根本不在页面里 —— 服务端在组装响应时就不下发,所以刷新、右键、F12 都提前看不到,你只看得到难度。点下去的那一刻同时起计时并跳转到 Codeforces。抽题按难度均匀取(不是按题目均匀),并会跳过本机已同步到的提交记录里出现过的题,包括只有 WA 的。
+「段位認定」是挑战模式:做完一道立刻抽下一道,一共三道,每道各自限时。三道全部在限时内通关才算通过;任何一道超时即判本轮失败并结束。一轮只能进行一个,中途可以放弃。挑战模式不发段位名,结算只给各题结果与总分 —— 给段位名的是固定选曲段,随机段位認定本来就不给。
+抽题与挑战的成绩走的是正常的计时练习那条链,所以照常计入 B50 与 DX Rating,可以在 DX 页面看到。最后请记住:这是自测,不是防作弊。开始做题之后你照样可以查题解、问 AI,这一点拦不住 —— 它唯一能保证的是你没法挑题。
+
\ No newline at end of file
diff --git a/src/dx/dan.ts b/src/dx/dan.ts
index 640620e..65eaf04 100644
--- a/src/dx/dan.ts
+++ b/src/dx/dan.ts
@@ -319,13 +319,26 @@ export function activeDanSession(db: DatabaseSync, userId: number): DanSessionRo
/**
* 「已经见过的题」——任何提交(不只是 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 a.is_archived=0 AND s.platform='codeforces'`).all(userId) as { problem_id: string }[];
+ 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());
diff --git a/tests/dan.test.ts b/tests/dan.test.ts
index ede1123..7c48768 100644
--- a/tests/dan.test.ts
+++ b/tests/dan.test.ts
@@ -265,6 +265,54 @@ test('已提交过的题不再抽到,且题号大小写不敏感', () => {
} 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 };
From 5e229faa27aec1a1dffe26e8923ffe17e7026121 Mon Sep 17 00:00:00 2001
From: Swan416ya <3447049424@qq.com>
Date: Fri, 9 Oct 2026 15:55:37 +0800
Subject: [PATCH 7/7] feat: rating-based daily problem and custom single draws
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
两件事,都是「把选题的权力从用户手里拿走」这条线的延续。
## 每日一题按水平定带
难度带以**用户当前的等效 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 才挪一次。如实写进文档。
- 两端裁进 [800, 2600]:水平很低收敛到 [800, 800],很高收敛到 [2600, 2600]
(2600 以上是 T97 外推段,与档位同一口径)。没有可计分的成绩时退回全段。
- 抽取仍是 (日期, 用户) 确定性,同一天刷新不变。
页面上把带子说出来:「等效 Rating 2199 → 2000–2400」。
## 自定义单题抽题(难度范围 + 标签)
「随机抽题」卡片新增自定义条件:难度 800–3500 + 标签多选(含任一)。
- 平台锁定 Codeforces:抽题要有全量题库,现在只有 CF 有;洛谷/码题集只有
提交抓取,而且洛谷难度是另一套制。标签来自**同一次** problemset.problems
抓取(CF 本来就带),不新增数据源 —— 缓存键 v1 → v2 就是为了把 tags 带上。
- 口径 A 原样适用:条件在 start 时发给服务端,题目只在服务端抽,点「开始做题」
之前页面里没有题号与链接。排除做过的题用同一套 danExcludedProblems。
- 标签取并集不是交集(「dp AND graphs AND greedy」在 11000 道里也常常是空集),
最多 8 个;题库里没有的标签 start 时直接 400。
- 条件落库(区间复用 dan_sessions 现有列,标签进 020 的 tags_json)并冻结进
结算快照;「再来一轮 · 自定义」带上当时的条件。限时随当题难度走。
- 自定义只用于单题模式,不跟段位認定的固定口径混。
## 迁移 020
只加一列:dan_sessions.tags_json(NULL = 无条件,020 之前的行解释不变)。
tier='custom' 复用 kind='single',不加 kind 值(SQLite 改不了 CHECK)。
**注意**:schema 升到 v20,已有数据库需要跑一次 npm run db:init 或 npm run sync。
## 验收
- tests/dan.test.ts 30 → **37/37**(新增 7 条:难度带的取整/裁剪/收敛、每日一题
落在带内、标签「含任一」与老缓存防御、自定义会话落库与限时、条件校验边界、
API 拒未知标签且不留半截轮次、API 等效 Rating 定带端到端)。
- 全量 302 → **309/309**。
- 真浏览器端到端三条全绿(新增 dan-custom-check.mjs):难度带说明、38 个标签的
标签云、按 1200–1600 + dp 抽一道、claim 揭示的题对着 v2 题库验**真的带 dp**、
结算链照常(CLEAR! → 查看本轮结算)。
- 旧两条 E2E(四道整轮结算、随机段位)回归全绿。
---
README.md | 2 +-
docs/dan.md | 38 ++++-
docs/development.md | 2 +-
public/dan.css | 27 ++++
public/dan.html | 15 +-
public/dan.js | 75 ++++++++-
public/help.html | 4 +-
src/db/database.ts | 1 +
src/db/migrations/020-dan-custom.sql | 9 ++
src/domain.ts | 6 +
src/dx/dan.ts | 131 +++++++++++++++-
src/fetchers/codeforces-sync.ts | 6 +-
src/server/api.ts | 65 +++++++-
tests/dan.test.ts | 217 ++++++++++++++++++++++++++-
14 files changed, 563 insertions(+), 35 deletions(-)
create mode 100644 src/db/migrations/020-dan-custom.sql
diff --git a/README.md b/README.md
index 5737eee..85e06e0 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
- **多平台记录**:集中查看 AC 记录、题目与逐次练习历史。
- **练习数据**:年度热力图、今日时间线、提交趋势和题目分布。
- **题友圈**:关注题友,查看他们在不同平台的做题动态。
-- **随机抽题与段位認定**:按档位抽题(初级/中级/上级/超上级),只看得到难度;点「开始做题」才揭题并起计时。也可以连做 4 道走一轮挑战,或者走不分段的**小随机**/**大随机**段位。
+- **随机抽题与段位認定**:按档位抽题(初级/中级/上级/超上级)或**自定义条件**(难度范围 + 标签),只看得到难度;点「开始做题」才揭题并起计时。也可以连做 4 道走一轮挑战,或者走不分段的**小随机**/**大随机**段位。**每日一题按你的水平定难度带**(等效 Rating ±200)。
- **本地优先**:数据保存在自己的电脑;零第三方运行依赖,无需构建。
## 功能预览
diff --git a/docs/dan.md b/docs/dan.md
index 523bbde..1b62d23 100644
--- a/docs/dan.md
+++ b/docs/dan.md
@@ -54,6 +54,30 @@ CF 题目的官方 rating 都是 100 的倍数(实测全量 11152 道有评级
每日一题用 `(日期, 用户)` 做种子确定性抽取:同一天怎么刷新都是同一道,换一天才换题。随机抽题与挑战模式用 `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:开始做题之前,链接不下发
**未开始的题目,题号与链接根本不会离开服务端。** 这不是「发到前端再藏起来」,而是服务端在组装响应时就不给:
@@ -179,12 +203,12 @@ active ──四道全部通关──→ cleared(通过)
## 数据
-迁移 `018-dan.sql` 与 `019-dan-random.sql`,两张表:
+迁移 `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`)。
+- `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 也就只需要一条加列。
+两个随机段位**没有新增 `kind`**:它们沿用 `kind='challenge'`,靠 `tier`(`small_random` / `big_random`)区分。加一个 `kind` 值要重建整张 `dan_sessions`(SQLite 改不了 `CHECK`),而 `tier` 已经能表达这件事,019 也就只需要一条加列。**自定义抽题同理**:`tier='custom'`,难度范围复用本来就有的 `min_rating` / `max_rating` 两列,020 只为标签加了一列。
两个设计点:
@@ -195,8 +219,8 @@ active ──四道全部通关──→ cleared(通过)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
-| GET | `/api/dan?user=&tz=` | 抽题面板与记录页。档位表(`tiers`)、两个随机段位(`randomTiers`)、题库是否就绪、每日一题的**难度**、进行中的一轮、历史记录 |
-| POST | `/api/dan/start` | 开一轮:`{userId, kind, tier?, tz?}`。kind 为 `daily` 时忽略 tier |
+| 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?}`。由「抽选下一题」触发 |
@@ -208,6 +232,6 @@ active ──四道全部通关──→ cleared(通过)
## 题库来源
-复用 `problemset.problems` 那份 6 小时缓存(`cf:problemset-ratings:v1`),**不新增数据源、不额外请求**。实测全量 11433 道题,其中 11152 道(97.5%)有官方 rating,上限 3500,不含 gym。各档位可用题量:初级 2312、中级 1875、上级 2578、超上级 2576;两个随机段位用的并集(800–2600)是 **9341** 道、19 个 rating 值(每个值都是 100 的倍数,最多的 800 分有 1104 道,最少的 2600 只有 347 道)。
+复用 `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 小时内复用。
\ No newline at end of file
+缓存过期时 `POST` 那几条路径会触发一次抓取(约 1.5 MB),之后 6 小时内复用。**v1 老缓存里没有 tags**:带标签条件抽题时把它当「没有标签信息」处理(一律不抽),而不是「这道题没有标签」。
\ No newline at end of file
diff --git a/docs/development.md b/docs/development.md
index 5df1e94..0ee4a95 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -80,7 +80,7 @@ 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 加了一列):随机抽题与段位認定。一轮认定一行(档位、`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 的档位统一限时。随机段位沿用 `kind='challenge'`,靠 `tier` 区分 —— 加 `kind` 值要重建整张表(SQLite 改不了 `CHECK`)。一个用户同时只能有一轮 `active`,由 partial unique index `one_active_dan_per_user` 兜底。题号与链接在 `claimed_at` 之前不写入响应,详见 [随机抽题与段位認定](dan.md)。
+- `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 必须明确告知这一行为。
diff --git a/public/dan.css b/public/dan.css
index 84f6d0b..fd3d0bc 100644
--- a/public/dan.css
+++ b/public/dan.css
@@ -55,6 +55,33 @@
.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;
diff --git a/public/dan.html b/public/dan.html
index fd496e2..5cc330c 100644
--- a/public/dan.html
+++ b/public/dan.html
@@ -30,7 +30,7 @@ 抽一道,别挑。
- 从四个档位的并集里按当天日期确定性抽取:同一天怎么刷新都是同一道。
+ 按你的水平定难度带,同一天怎么刷新都是同一道。
@@ -38,6 +38,19 @@ 抽一道,别挑。
按 rating 均匀抽取,不是按题目均匀 —— 否则「初级」会几乎全是 800 分。抽完只显示难度。
+
+
自定义条件
难度范围 + 标签
+
+
+ 800–3500 · 2600 以上是外推段
+
+
+
不限标签
+
diff --git a/public/dan.js b/public/dan.js
index 11321f1..989ee06 100644
--- a/public/dan.js
+++ b/public/dan.js
@@ -7,6 +7,8 @@ import { cfRatingColor } from './cf-rating-colors.js';
* 这一页刻意**不缓存、不预取**任何题目信息:服务端在下发时就不给未开始的题号与链接
* (见 src/dx/dan.ts 的口径 A),所以这里也没有任何「提前藏起来」的东西可以做 ——
* 唯一的入口是 beginStage(),它在 claim 成功之后才拿到 url 并立刻跳转。
+ * 自定义抽题(难度范围 + 标签)走同一条链:条件在 start 时发给服务端,
+ * 题目照样只在服务端抽,页面在这之前拿不到它是哪一道。
*
* 结算是两层的,和街机原作一致:
* - 每道题做完 → 单题结算(复用 public/dx-result.css 那套,与普通计时结算同一张皮);
@@ -200,10 +202,18 @@ function renderIdle() {
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 ? '今天已经没有你没做过的题了。' : '题库缓存未就绪。'));
+ else dailyHost.append(el('p', 'dan-note', board.poolReady ? '这个难度带里今天已经没有你没做过的题了。' : '题库缓存未就绪。'));
$('dailyStart').disabled = busy || !board.daily;
const singles = $('singleTiers');
@@ -212,6 +222,41 @@ function renderIdle() {
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) {
@@ -272,8 +317,10 @@ function renderActive() {
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', `限时 ${fmtLimit(session.limitSeconds)} / 道`));
+ // 逐题限时的模式报不出确定的数,就照实说(与档位按钮同一句话)。
+ 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));
@@ -339,6 +386,7 @@ function runCard(run) {
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);
@@ -599,7 +647,8 @@ function openRunResult(run) {
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.draw === 'problem' ? '按题目均匀' : '按 rating 均匀'}`
+ + (run.tags?.length ? ` · 标签 ${run.tags.join('、')}` : '');
const tracks = field(dialog, '.dan-result-tracks');
tracks.replaceChildren();
@@ -681,20 +730,27 @@ function openRunResult(run) {
const again = field(dialog, '.dan-result-again');
again.textContent = `再来一轮 · ${run.tierName}`;
- again.onclick = () => { dialog.close(); startRun(run.kind === 'challenge' ? 'challenge' : 'single', run.tier); };
+ 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) {
+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 });
+ await post('/api/dan/start', { userId, kind, tier, tz, ...extra });
await refresh();
setMessage('');
} catch (error) {
@@ -768,6 +824,13 @@ function tick(node) {
}
$('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()
diff --git a/public/help.html b/public/help.html
index 3b9d4a0..b22843c 100644
--- a/public/help.html
+++ b/public/help.html
@@ -116,7 +116,9 @@ 2. 取得 API key / secret 后配置
实现及研究记录保存在项目 docs/cf-maimai-study.md、docs/backtesting.md;界面中的参数和验证状态来自服务端当前模型。
10. 随机抽题与段位認定
-不想挑题的时候,让服务端替你抽一道。打开导航里的「随机抽题」,选一个档位即可。四个档位按 Codeforces 官方难度划分,覆盖 800–2600:初级 800–1100(限时 30 分钟)、中级 1200–1500(40 分钟)、上级 1600–2000(50 分钟)、超上级 2100–2600(60 分钟)。「每日一题」不分档位,从这四档的并集里抽,限时 45 分钟,同一天怎么刷新都是同一道。
+不想挑题的时候,让服务端替你抽一道。打开导航里的「随机抽题」,选一个档位即可。四个档位按 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 分钟),不是一刀切。
diff --git a/src/db/database.ts b/src/db/database.ts
index 55f4795..f43d6d5 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -24,6 +24,7 @@ const MIGRATIONS: readonly { appliesWhenAtMost: number; file: string }[] = [
{ 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/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
index 65eaf04..e097c57 100644
--- a/src/dx/dan.ts
+++ b/src/dx/dan.ts
@@ -7,8 +7,10 @@ import { scoreProblem } from './rating.ts';
* 随机抽题、每日一题与段位認定(挑战模式)—— **唯一实现**。
*
* 三件事共用同一套抽题引擎:
- * 1. **随机题目**:选一个档位,抽一道题(`single`)
- * 2. **每日一题**:按(日期 + 用户)确定性抽一道,同一天刷新不变(`daily`)
+ * 1. **随机题目**:选一个档位,抽一道题(`single`);也可以**自定义**难度范围与
+ * 标签(tier=`custom`,条件存 `dan_sessions` 的区间列与 `tags_json`)
+ * 2. **每日一题**:按(日期 + 用户)确定性抽一道,同一天刷新不变(`daily`);
+ * 难度带**按用户当前的等效 Rating 定**(±200,见 `dailyBand`)—— 不再全段乱抽
* 3. **段位認定**:同一档位连抽 4 道、逐题限时,全部通关才算通过(`challenge`)
* 四个难度档之外还有两个**随机段位**:小随机与大随机(`DAN_RANDOM_TIERS`)。
* 注意口径:这是**挑战模式**,不发段位名。maimai 的随机段位認定同样不发段位名,
@@ -112,6 +114,26 @@ export const DAN_RANDOM_TIERS: readonly DanTier[] = [
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',
@@ -121,6 +143,7 @@ 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;
}
@@ -194,6 +217,12 @@ export interface DanPoolOptions {
exclude: ReadonlySet;
/** 抽题分布,默认 `rating`(口径 B)。`problem` 只有「大随机段位」用。 */
draw?: 'rating' | 'problem';
+ /**
+ * 标签条件(自定义抽题):非空时只抽**至少带其中一个标签**的题(「含任一」)。
+ * 用「含任一」而不是「全含」—— 全含会把池子筛到只剩几道,「dp AND graphs AND
+ * greedy」这种交集在 11000 道的题库里也常常是空的。
+ */
+ tags?: ReadonlySet;
}
/** 注入随机源,便于单测;生产用 crypto(服务端不可预测,见口径 A)。 */
@@ -209,11 +238,15 @@ const defaultRandomInt: DanRandomInt = (maxExclusive) => cryptoRandomInt(maxExcl
*/
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]);
}
@@ -267,6 +300,7 @@ export function dailyDanCandidate(
export type DanErrorCode =
| 'TIER_UNKNOWN'
+ | 'CUSTOM_INVALID'
| 'SESSION_ACTIVE'
| 'SESSION_NOT_FOUND'
| 'POOL_EMPTY'
@@ -287,6 +321,8 @@ export interface DanSessionRow {
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 {
@@ -346,35 +382,106 @@ export function danExcludedProblems(db: DatabaseSync, userId: number, sessionId?
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: DAILY_TIER.minRating, maxRating: DAILY_TIER.maxRating, exclude: danExcludedProblems(db, userId) },
+ { 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 },
+ input: { id: string; userId: number; kind: DanKind; tierKey: string; now: number;
+ minRating?: number; maxRating?: number; tags?: readonly string[] },
): DanSessionRow {
- const tier = input.kind === 'daily' ? DAILY_TIER : selectableDanTier(input.tierKey);
+ 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)
- VALUES(?,?,?,?,?,?,?,?,'active',?)`)
+ (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, tier.minRating, tier.maxRating, input.now);
+ 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)!;
});
}
@@ -397,11 +504,14 @@ export function drawNextDanStage(
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)。
@@ -545,6 +655,8 @@ function finishDanSession(db: DatabaseSync, session: DanSessionRow, status: 'cle
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,
};
@@ -616,6 +728,8 @@ export interface DanSessionView {
draw: 'rating' | 'problem';
/** true = 逐题限时按当题难度,此时 `limitSeconds` 只是展示回退值。 */
perStageLimit: boolean;
+ /** 自定义抽题的标签条件;null = 没有条件。 */
+ tags: string[] | null;
stages: DanStageView[];
/** 结算只出单题成绩与总分,**不发段位名**(随机段位認定是挑战模式)。 */
selfReported: true;
@@ -639,6 +753,7 @@ export function danSessionView(db: DatabaseSync, session: DanSessionRow): DanSes
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,
};
diff --git a/src/fetchers/codeforces-sync.ts b/src/fetchers/codeforces-sync.ts
index f1624f8..5ad7730 100644
--- a/src/fetchers/codeforces-sync.ts
+++ b/src/fetchers/codeforces-sync.ts
@@ -13,7 +13,7 @@ const PROBLEMSET_URL = 'https://codeforces.com/api/problemset.problems';
* 全量题库的缓存键。导出是为了让调用方(读接口、测试)能直接定位这份缓存,
* 而不必在别处硬编码同一个字符串。
*/
-export const PROBLEM_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;
@@ -75,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 c645e6f..e1cef62 100644
--- a/src/server/api.ts
+++ b/src/server/api.ts
@@ -74,6 +74,7 @@ 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,
@@ -89,6 +90,7 @@ import {
danProblemUrl,
danSessionView,
danStageLimitOf,
+ dailyBand,
dailyProblem,
drawNextDanStage,
type DanKind,
@@ -722,6 +724,40 @@ 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));
+}
+
/**
* 抽题面板与记录页的只读视图。
*
@@ -734,7 +770,9 @@ function danOverview(ctx: ApiContext, userId: number, tzOffsetMinutes: number):
const pool = cachedProblemRatings(new Repository(ctx.db));
const now = Math.floor(Date.now() / 1000);
const dateKey = danDateKey(now, tzOffsetMinutes);
- const daily = pool ? dailyProblem(ctx.db, userId, pool, dateKey) : null;
+ // 每日一题的难度带按用户当前的等效 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,
@@ -745,8 +783,11 @@ function danOverview(ctx: ApiContext, userId: number, tzOffsetMinutes: number):
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 })),
- dailyTier: { name: DAILY_TIER.name, minRating: DAILY_TIER.minRating, maxRating: DAILY_TIER.maxRating,
- limitSeconds: DAILY_TIER.limitSeconds },
+ 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,
@@ -953,12 +994,24 @@ 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: kind === 'daily' ? DAILY_TIER.key : text(input, 'tier', 40), now });
- const pool = await danPool(db);
+ 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,
diff --git a/tests/dan.test.ts b/tests/dan.test.ts
index 7c48768..a647d27 100644
--- a/tests/dan.test.ts
+++ b/tests/dan.test.ts
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
import { openDatabase, Repository } from '../src/db/database.ts';
import { submission } from '../src/fetchers/common.ts';
import { reconcileTimers, startTimer } from '../src/dx/timer.ts';
-import { listPractice } from '../src/dx/practice.ts';
-import { scoreProblem } from '../src/dx/rating.ts';
+import { listPractice, recordPractice } from '../src/dx/practice.ts';
+import { scoreProblem, lookupT97 } from '../src/dx/rating.ts';
import { listDxEntries } from '../src/server/queries.ts';
import { handleApi, WRITE_ROUTES } from '../src/server/api.ts';
import { PROBLEM_RATINGS_CACHE_KEY } from '../src/fetchers/codeforces-sync.ts';
@@ -28,6 +28,7 @@ import {
danProblemId,
danSessionView,
danTier,
+ dailyBand,
dailyDanCandidate,
drawDanCandidate,
drawNextDanStage,
@@ -330,6 +331,218 @@ test('danDateKey 按给定时区换算日期', () => {
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 {