Skip to content

feat: add random problem draw and dan challenge mode - #1

Merged
fbd777 merged 7 commits into
fbd777:mainfrom
Swan416ya:feat/dan-challenge
Oct 9, 2026
Merged

fbd777 merged 7 commits into
fbd777:mainfrom
Swan416ya:feat/dan-challenge

Conversation

@Swan416ya

Copy link
Copy Markdown
Contributor

照舞萌 DX 的随机段位認定做了一个抽题玩法:题由服务端抽,一开始只告诉你难度;点「开始做题」才揭题并起计时。挑战模式是连做 3 道、逐题限时。

做了什么

  • 四个档位(按 CF 官方题目难度):初级 800–1100 / 中级 1200–1500 / 上级 1600–2000 / 超上级 2100–2600,单题限时 30 / 40 / 50 / 60 分钟。区间互不重叠、并集正好覆盖 800–2600。
  • 每日一题:四档并集(800–2600)里按 (日期, 用户) 做种子确定性抽取,同一天怎么刷新都是同一道,限时 45 分钟。
  • 挑战模式:3 道一循环,做完一道立刻抽下一道;任一道超时即判本轮失败并结束;一轮只能有一个,可中途放弃;抽了不点 24 小时后过期作废。
  • 记录页:历史认定记录,每题结果 + 总分。

两个关键口径

1. 开始做题之前,链接不下发

不是「发到前端再藏起来」,而是服务端组装响应时就不给:未 claim 的 stage 只含 difficulty,problemId / problemUrl 都是 null(danStageView 里只有 claimed_at !== null 才填)。唯一的出口是 POST /api/dan/claim,它同时起计时器、写 claimed_at、返回链接,前端拿到就立刻 location.href 跳走。

所以刷新、右键、F12、看网络请求都拿不到 —— 那道题的链接在那个时刻还不存在于任何下发数据里。刻意没做右键 / F12 屏蔽(拦不住,而且会干扰正常使用),也没做前端混淆或 base64 藏链接(那同样是「已经发下去了」,只是难看)。

tests/dan.test.ts 有断言直接搜序列化后的响应体:claim 之前不得出现 codeforces.com,也不得出现 contest:index 形态的题号;API 层另有一条覆盖 GET /api/dan。

2. 不发段位名

结算只给各题结果与总分(三道单题 rating 之和)和通过 / 未通过,没有段位名,也没有 AAA / S 之类的分数线。这是照舞萌 DX 的规则:随机段位認定本来就不给段位名,给段位名的是固定选曲段。

抽题口径

按 rating 均匀,不按题目均匀。 题库里 800–899 有 1104 道,900–999 只有 354 道,再往上大致每 100 分 450–540 道。直接对题目集合随机挑,「初级」会几乎全是 800 分。所以先在区间内的 rating 值之间均匀选一个,再在该 rating 的题目里随机选一道。

排除做过的题,包括只有 WA 的。依据是本机已同步的提交记录,同步不完整时可能漏网 —— 页面上写明了这一点。

成绩去向

挑战与抽题的成绩走正常的计时练习那条链(复用 practice_timers 与现有的结算),所以照常计入 B50 与 DX Rating,不是另一套计分。

自测,不是防作弊

开始做题之后照样可以查题解、问 AI,这一点拦不住 —— 和「辅助解题」自报标签是同一条现实。这个功能能保证的只有「你没法挑题」。文档和页面上都写明了这条边界。

改动

文件 说明
src/db/migrations/018-dan.sql dan_sessions / dan_stages,含 one_active_dan_per_user partial unique index
src/dx/dan.ts 档位、抽题、状态机、结算(唯一实现处)
src/server/api.ts 5 个端点 + WRITE_ROUTES + DanError 映射(已有活动轮次回 409)
src/dx/timer.ts reconcileTimers 末尾接一次 advanceDanSessions(单向依赖 timer → dan)
src/fetchers/codeforces-sync.ts 导出 cachedProblemRatings,复用同一份 6 小时题库缓存
public/dan.{html,css,js} 抽题台、挑战面板、记录页;其余 5 个页面加导航项
tests/dan.test.ts 21 个测试
docs/dan.md 设计口径文档

复用 problemset.problems 那份 6 小时缓存(cf:problemset-ratings:v1),不新增数据源、不额外请求,零新增运行依赖、无需构建。

两个实现细节

  • 难度在抽题时快照进 dan_stages.difficulty,结算不重查题库;否则缓存过期或 CF 修订题目 rating 会让历史记录跟着变。settlement_json 同样在结束时冻结。
  • 读接口只从 fetch_cache 读(读连接是 query_only,不能写),冷缓存时如实返回 poolReady: false,由前端引导走一次写接口去抓,而不是让 GET 触发网络请求。

测试

node --test tests/dan.test.ts → 21/21 通过。覆盖:区间互不重叠与限时随难度递增、抽题总是落在区间内、rating 均匀性(用注入的 RNG 构造「999 道 800 分 + 1 道 1100 分」的偏斜池)、已做过的题大小写不敏感地排除、每日一题可复现、claim 之前响应体里没有链接、claim 之后才揭示、3 道全部通关才判通过、超时判失败并释放计时器、放弃、TTL 作废、单活动轮次(应用层 + 数据库索引各一条)、POOL_EMPTY、以及挑战成绩确实进入 listPractice(timing_source 报 timer)与 listDxEntries,且与 scoreProblem 得到同一个分数。

全量 node --test tests/*.test.ts → 293 项 292 通过。唯一失败的是 tests/cf-group-ui.test.ts(真实 Edge + CDP 连接,10 秒预算)。它在未做任何改动的 4426f3c 上同样会失败,单独跑 3/3 通过、全量并行时偶发,属于既有的负载敏感 flake,与本 PR 无关。

另外我在真实服务上端到端跑过一遍:抽题拿到 11152 道题的池子、响应体不含链接、claim 返回 https://codeforces.com/contest/213/problem/C 且 deadlineAt - serverNow 正好 3000 秒、重复 claim 幂等(同链接同计时器)、第二轮 409、放弃后计时器被释放并记为 interrupted。

升级提醒

这是 schema v18。面板按既有设计拒绝自己跑迁移,所以已有数据库升级后需要跑一次 npm run db:init(或 npm run sync)—— 全新数据库不受影响。

我在一个已有的 v17 库上验证过迁移:干净补齐到 v18,两张表与三个索引都在。

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.
CF problem ratings are all multiples of 100 -- the 11152 rated problems use
only 28 distinct values -- so each tier covers just 4-6 difficulty levels
and rating-uniform drawing means those levels are equally likely. Spell out
the actual bucket sizes, because "uniform over ratings" otherwise reads as
if it were uniform over a hundred-odd values.
段位認定现在有两层结算,整轮那一页照街机原作做:

- 单题结算复用普通计时练习那张皮(dx-result),显示达成率、评级、用时、
  判定计数、单题 rating,以及结算那一刻的 DX Rating 总分与增量 —— 对比读的是
  practice_timers.settlement_json 里冻结的那一份,不另算一套分。
- 整轮结算页:合格 / 不合格 印章(缎带 + 两侧扇饰 + 合格文字 + 星尘,失败换红
  且不出星尘,放弃换灰)、逐题卡(卡顶挂 CLEAR / TIME UP 印、显示评级与达成率、
  底部一条达成率进度条)、四块合计(通关题数 / 单题合计 / 用时合计 / B50 增量)。
- 版式取自 156 版《舞萌 DX》素材包 resources.assets 里的 UI_DNM_Result_ClearBase_*、
  UI_DNM_Clear_Parts_*、UI_DNM_Icon_Clear/_NoClear 与 UI_DNM_DaniChainCard_*,用
  UnityPy 解出的 GameObject / RectTransform 层级量出尺寸(底板 312x56、扇饰
  116x116 落在 ±66、缎带 212x72、合格文字 128x64)。贴图全部用 CSS 重画,没有把
  游戏美术搬进仓库。

下一道不再由轮询自动抽:

- 新增 POST /api/dan/settle:只结算、不抽题,轮询改走这条;
- 单题结算上的「抽选下一题」才调 /api/dan/next 抽下一道;
- 最后一道的按钮是「查看本轮结算」,点了进整轮结算页。

顺带修掉两个真实缺陷:

- dan.js 重写时漏了 lastActiveId 赋值,三道打完的那一轮不会弹结算;
- 服务端同步任务常常比页面刷新更快结算完,页面首帧看到的就是「已结束」,
  成绩因此看不到;现在会把 90 秒内刚结束的那一轮补弹一次(只补一次)。

超时口径的文档原来写错了(说按实际用时计分):实际是超时那道计时器被取消、
不写 score_json、不生成 practice 记录,这里改成了如实描述。

测试:tests/dan.test.ts 21 → 23 条,新增「settle 只结算不抽题」「未 claim 的题
不下发题目名与结算数据」;全量 295 条通过。另有一份真浏览器端到端验收(headless
Edge + CDP)跑完三道挑战与一条超时:单题结算的按钮文案、点下去确实抽出下一道、
数据库里 dan_stages 随之增加、结算页印章与三张题卡的字段、零 console 报错。
上一版把结算页做错了:我照着 UI_DNM_Clear 拼了一个「合格 / 不合格」大印章,但那是
**单曲**的 CLEAR 印,不是整轮结算。整轮结算(Process/Course/CourseResultProcess)实际
长这样,现在照它重做:

- 奶油色纸盘 + 橙红双圈描边 + 两侧水引红绳(蝴蝶结 / 竖绳 / 流苏)。
- 逐曲卡:左侧八角「可」印(未通关「不可」、未开始灰),右侧难度配色横卡 ——
  封面格放题号、STAGE 0n / 难度 / 题目名、达成率 + 金色描边大数字 + 判定、
  最右 LV 与「DX分数」(该题单题 rating)。
- 底部:判定框(合格 / 不合格 + 通关题数)· 一颗心 · 总达成率 + DX分数。
- 右下角「好」按钮 + 「再来一轮 · 档位」。

两处按我们的规则刻意改写:原作那一格是段位名(十段),随机段位認定不发段位名,
改成写判定结果;原作那颗心是剩余生命,我们没有生命值,用它显示通关几道。
「总达成率」对应三道达成率之和,「DX分数」对应三道单题 rating 之和(totalRating)。

贴图仍全部用 CSS 重画,没有把游戏美术搬进仓库。盘面用大圆角而不是正圆 ——
三道题卡竖排进正圆在常见宽度下会挤到不可读。

抠图像素数出来的两个静默失败(DOM 层面完全看不出来,都是装饰层):

- 金色数字:八向 text-shadow 的偏移副本把渐变填充整个盖住,只剩棕描边;
  改用 -webkit-text-stroke 后仍然不行 —— Chromium 的 paint-order 只对 SVG 生效,
  HTML 文字上描边照样盖在填充之上,6px 描边直接吃掉整个笔画。最后描边走 ::before、
  渐变填充走 ::after 分两层才真正画出来。
- 「可」印:background-image 把 #fff8ec 底色盖成了纯红,红字印在红底上等于看不见。

验收:tests/dan.test.ts 23/23、全量 295/295;真浏览器端到端全绿,并且对金色数字 /
绿心 / 可印做了抠图像素探针(金色 19.3%、绿心 18.7%、可印奶油底 64.3% + 红 14.7%),
确保这四块装饰真的着色了。
两件事。

## 一轮改成四道

`DAN_STAGES` 3 → 4。四个难度档与每日一题都跟着变成四道;结算页、进度带、
说明文案里的「三道」全部改成四道(docs / README / help.html / dan.js 一起清)。

## 新增小随机段位与大随机段位

两个**随机段位**:rating 不分段,取四个难度档的并集 800–2600(9341 道、19 个
rating 值),也是四道、也逐题限时。区别只有抽题分布:

- **小随机段位**(`draw: 'rating'`):按 rating 均匀,每个难度值等概率(1/19)。
- **大随机段位**(`draw: 'problem'`):按题目均匀,**不保证难度分布**。

实测量到的东西写进 docs/dan.md 了,其中一个必须说清楚:大随机在我们的题库里
**没有**「四道全是 800」那么极端 —— CF 的 800 分题只占池子的 11.8%(舞萌的简单曲
占比要悬殊得多)。它实际只是把 800 分的概率从 5.3% 抬到 11.8%、期望难度从 1700
压到 1637。顺带一条反直觉的:大随机**反而更难**抽到超上级(27.6% vs 小随机的
31.6%,因为 800 那一桶把份额吸走了)。

两者都设 `perStageLimit`:抽到的题横跨 800–2600,统一限时对两头都不公平,所以
**限时随题走**(≤1100 → 30 分、1200–1500 → 40 分、1600–2000 → 50 分、2100+ → 60 分),
在抽到的那一刻连同 difficulty 一起快照进库。

## 迁移 019

只加一列:`dan_stages.limit_seconds`(NULL = 019 之前的旧行,沿用
`dan_sessions.limit_seconds`,旧记录的解释一个字不变)。

没有重建 `dan_sessions`:随机段位沿用 `kind='challenge'`,靠 tier
(`small_random` / `big_random`)区分 —— 加一个 `kind` 值要重建整张表(SQLite 改不了
`CHECK`),而 `tier` 已经能表达这件事。

**注意**:schema 升到 v19,已有数据库需要跑一次 `npm run db:init` 或 `npm run sync`;
面板自身不负责迁移。

## 验收

- `tests/dan.test.ts` 28/28(新增 4 条:大随机按题目均匀的分布、两个随机档位的
  区间与限时映射、随机段位落库后逐题限时进结算快照、019 前的旧行回退到 session 限时)。
- 全量 295 → **300/300**。
- 真浏览器端到端两条全绿:
  - `dan-settlement-check.mjs`:上级档四道一轮 + 一条超时,结算页 / 单题 / 不提前下发链接;
  - `dan-random-check.mjs`(新):随机段位卡片文案、点大随机开一轮、四道的难度与限时
    逐一对上当题难度、claim 的 deadline 也是当题限时、结算页逐曲卡各带自己的限时。
- 金色数字 / 绿心 / 「可」印的抠图像素探针继续全过。
用户要求「段位里不要出现我已经 AC 过的题」。实测现有的排除是生效的 ——
池子里 65 道已 AC 的题,对照组(不带排除)抽 4000 次命中其中 19 道,
带排除后六种模式各抽 20000 次零命中。但探针翻出两个洞:

## 1. 归档账号上的题会重新变成可抽(真 bug)

`danExcludedProblems` 带了 `a.is_archived = 0`,与 B50 / 题友圈 / 练习记录
「只算当前账号」的口径一致。但这个条件在**排除**这件事上是错的:归档的含义是
「这个账号不再同步了」(换 handle、换号时旧账号会被归档),**不是**「那些题我
没做过」。带上它的后果是:换过一次 handle 的人,之前 AC 过的题全部重新可抽 ——
正是「别抽我做过的题」要防的事。

去掉了这个条件。

## 2. 正在计时的题没被排除(窄但真实)

「专注计时」可以不经提交、直接对题号开始。于是段位有可能抽到你手上正在做的
那道;更糟的是 `practice_timers` 上有 `one_running_timer_per_user`,点「开始做题」
时 `startTimer` 会直接失败,把这一道卡死。

把 `practice_timers` 里的题号也并进排除集。

## 验收

真实题库(9341 道)上,造一个「旧账号已归档、上面 40 道 AC」的库:

- 排除集 89 → 129;池子里 105 道已 AC,**0 道漏出**
- 小随机 / 大随机 / 四个档位各抽 20000 次 → **零命中**;对照不带排除命中 35 道
- 每日一题也不是已 AC 的题

新增 2 条单测(归档账号的题排除、正在计时的题排除),`tests/dan.test.ts`
30/30,全量 **300 → 302/302**,两条真浏览器端到端继续全绿。

docs/dan.md 的「排除做过的题」与页面上的口径说明都改成了三类题 + 同步不全
仍可能漏网的实话。
两件事,都是「把选题的权力从用户手里拿走」这条线的延续。

## 每日一题按水平定带

难度带以**用户当前的等效 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(四道整轮结算、随机段位)回归全绿。
@fbd777
fbd777 merged commit f283519 into fbd777:main Oct 9, 2026
@fbd777

fbd777 commented Oct 9, 2026

Copy link
Copy Markdown
Owner

随机段位认证有点bug,完善了一下,已合并

@fbd777

fbd777 commented Oct 9, 2026

Copy link
Copy Markdown
Owner

合并到的release目前很多逻辑比较粗糙,我打算把这个机制再改一版,改完随后上传新的随机段位认定正式版

@Swan416ya

Copy link
Copy Markdown
Contributor Author

合并到的release目前很多逻辑比较粗糙,我打算把这个机制再改一版,改完随后上传新的随机段位认定正式版

沃趣我还没写完 忘记丢draft了(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants