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。

+
+ +

+ + + + + +
+

认定记录

+
+
+ +

口径:这是自测,不是防作弊。开始做题之后你照样可以查题解、问 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
+

+
+
DX
+

+
LEVEL
+
+
达成率 ›››✦
+
此前最佳
+
+
+
+
+
ACAccepted
+
CSClean Solve
+
PLAY TIME
+
+
Rating
+
DXRATING
+
+
+
`; + 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

    使用指南

    从绑定第一个账号,到记录练习、看懂成绩与统计。

    前往账号管理 ↗
    - +

    1. 需要注册吗?

    Algorithm DX 是本机应用,没有单独的注册、密码或云端账号。你需要在 Codeforces、码蹄集等平台上已有账号;如果没有,先在对应平台官网完成注册。

    1. 打开首页「账号管理」,在「这个账号属于谁」中选择「添加一个人」。
    2. 起一个本地显示名称,例如“我”,并勾选「标记为我」。同一个人的其他平台账号以后选这个名称,不要重复建人。
    3. 选择平台,按输入框下的提示填写用户名或数字 ID。需要登录 Cookie 的平台可按下面的通用 Cookie 教程填写。
    4. 点击「保存账号」,再点「同步最新数据」。保存不等于已经抓取了数据。

    添加关注的人:为对方建一个本地名称,不勾选「我」,再把其公开平台账号绑定到这个名称。关注仅影响本机展示,不会在原平台发起关注或通知对方。码蹄集登录后可查询网站允许查看的其他用户做题记录。

    2. 本地名称、昵称、账号 ID 有什么区别?

    本地名称由你起,方便把同一个人的多个平台账号放在一起。平台用户名 / ID用来找到真正的账号,必须按个人主页地址填写。

    不要填写密码、电子邮箱或完整主页网址来代替账号 ID。输入框下也会随平台显示对应例子。

    +

    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,这一点拦不住 —— 它唯一能保证的是你没法挑题。

    +