koishi-plugin-noah 1.8.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,14 @@ export declare class ServerService {
22
22
  * @returns 该频道绑定的服务器完整信息列表
23
23
  */
24
24
  getServersByCid(cid: string): Promise<Server[]>;
25
+ /**
26
+ * 获取可供选择的服务器列表(合并用户与频道)
27
+ * 对于 mao 与 official 类型,由于其 URL 固定,仅保留其中一个实例
28
+ * @param uid - 用户ID
29
+ * @param cid - 频道ID,可为空
30
+ * @returns 去重后的服务器列表
31
+ */
32
+ getSelectableServers(uid: number, cid?: string | null): Promise<Server[]>;
25
33
  /**
26
34
  * 根据服务器ID获取服务器信息
27
35
  * @param id - 服务器ID
@@ -8,6 +8,11 @@ import { BaseDrawer } from '../BaseDrawer';
8
8
  export declare class SDVXDrawer extends BaseDrawer {
9
9
  ctx: Context;
10
10
  constructor(ctx: Context);
11
+ /**
12
+ * Format difficulty level text.
13
+ * If it's an integer like 17 / 20, keep trailing ".0" (e.g. "17.0").
14
+ */
15
+ private formatDifficultyLevel;
11
16
  /**
12
17
  * 生成SDVX最近成绩图像
13
18
  * @param options - 图像生成配置选项
@@ -0,0 +1,3 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { SDVXConfig } from '../../../types/config';
3
+ export declare function sync(ctx: Context, config: SDVXConfig, logger: Logger): void;
@@ -1,3 +1,15 @@
1
1
  import { Context } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Tables {
4
+ sdvx_pin_verified: SdvxPinVerified;
5
+ }
6
+ }
7
+ export interface SdvxPinVerified {
8
+ id: number;
9
+ uid: number;
10
+ cardId: number;
11
+ sid: number;
12
+ pin: string;
13
+ }
2
14
  export declare const name = "database";
3
15
  export declare function apply(ctx: Context): void;
@@ -6,6 +6,8 @@ export declare class MusicService {
6
6
  private sdvx_data_url;
7
7
  private sdvx_search_url;
8
8
  private constructor();
9
+ private parseDifnum;
10
+ private normalizeMusicDifnum;
9
11
  /**
10
12
  * 获取 MusicService 实例
11
13
  * @param config - SDVX 配置对象
@@ -32,4 +34,8 @@ export declare class MusicService {
32
34
  * @returns 音乐信息数组
33
35
  */
34
36
  searchMusic(ctx: Context, query: string): Promise<SDVXMusic[]>;
37
+ extTitleToMid(ctx: Context, titles: string[]): Promise<{
38
+ title: string;
39
+ mid: string | null;
40
+ }[]>;
35
41
  }
@@ -1,7 +1,7 @@
1
1
  import { SDVXGrade, SDVXClearType } from '../types';
2
2
  /**
3
3
  * VF 查找表类型
4
- * Key: `${level}-${grade}-${clearType}-${vf}`
4
+ * Key: `${levelScaled}-${grade}-${clearType}-${vfInt}`
5
5
  * Value: QueryResult(每个组合只保留最小分数)
6
6
  */
7
7
  type VFLookupTable = Map<string, QueryResult>;
@@ -15,6 +15,10 @@ export interface QueryParams {
15
15
  export declare function parseNumberWithSuffix(str: string): number;
16
16
  /**
17
17
  * 解析等级范围
18
+ * 规则:
19
+ * - 1-16: 只能是整数(用户输入整数时看作 xx.0)
20
+ * - 17: 可以是 17.0 或 17.5
21
+ * - 18-20.9: 0.1 步进(18.0, 18.1, ..., 20.9)
18
22
  */
19
23
  export declare function parseLevelRange(item: string): number[] | null;
20
24
  /**
@@ -5,6 +5,7 @@ import { SDVXGrade, SDVXClearType, SDVXScore } from '../types';
5
5
  export declare const FACTOR_SCALE = 100;
6
6
  /**
7
7
  * VF 公式中的常量(分数与系数乘积乘以缩放后再取整)。
8
+ * 为了支持小数等级,level会被乘以10,所以分母也需要乘以10。
8
9
  */
9
10
  export declare const VOLFORCE_BASE_DENOMINATOR: bigint;
10
11
  /**
package/lib/index.cjs CHANGED
@@ -221,7 +221,6 @@ var AssetService = class {
221
221
  */
222
222
  async extractAssets(archivePath) {
223
223
  const extractPath = (0, import_path.resolve)(this.basePath, "assets");
224
- await (0, import_promises.mkdir)(extractPath, { recursive: true });
225
224
  this.logger.info(`Extracting assets to ${extractPath}`);
226
225
  try {
227
226
  if (!import_fs.default.existsSync(archivePath)) {
@@ -232,6 +231,11 @@ var AssetService = class {
232
231
  } catch (err) {
233
232
  throw new Error(`Archive file is not readable: ${archivePath}`);
234
233
  }
234
+ if (import_fs.default.existsSync(extractPath)) {
235
+ this.logger.info(`Removing existing assets: ${extractPath}`);
236
+ import_fs.default.rmSync(extractPath, { recursive: true, force: true });
237
+ }
238
+ await (0, import_promises.mkdir)(extractPath, { recursive: true });
235
239
  const zip = new import_adm_zip.default(archivePath);
236
240
  const zipEntries = zip.getEntries();
237
241
  this.logger.info(`Found ${zipEntries.length} files in archive`);
@@ -523,6 +527,32 @@ var ServerService = class {
523
527
  rows.map((obj) => obj.sid)
524
528
  );
525
529
  }
530
+ /**
531
+ * 获取可供选择的服务器列表(合并用户与频道)
532
+ * 对于 mao 与 official 类型,由于其 URL 固定,仅保留其中一个实例
533
+ * @param uid - 用户ID
534
+ * @param cid - 频道ID,可为空
535
+ * @returns 去重后的服务器列表
536
+ */
537
+ async getSelectableServers(uid, cid) {
538
+ const channelServers = cid ? await this.getServersByCid(cid) : [];
539
+ const userServers = await this.getServersByUid(uid);
540
+ const merged = [];
541
+ const seenIds = /* @__PURE__ */ new Set();
542
+ const seenFixedTypes = /* @__PURE__ */ new Set();
543
+ for (const server2 of [...channelServers, ...userServers]) {
544
+ if (seenIds.has(server2.id)) continue;
545
+ if ((server2.type === "mao" || server2.type === "official") && seenFixedTypes.has(server2.type)) {
546
+ continue;
547
+ }
548
+ if (server2.type === "mao" || server2.type === "official") {
549
+ seenFixedTypes.add(server2.type);
550
+ }
551
+ seenIds.add(server2.id);
552
+ merged.push(server2);
553
+ }
554
+ return merged;
555
+ }
526
556
  /**
527
557
  * 根据服务器ID获取服务器信息
528
558
  * @param id - 服务器ID
@@ -1002,8 +1032,12 @@ function bind(ctx, config) {
1002
1032
  case "invalid":
1003
1033
  return session.text(".invalid-code");
1004
1034
  case "access":
1005
- cardCode = await accessToUid(ctx, cardCode);
1006
- break;
1035
+ try {
1036
+ cardCode = await accessToUid(ctx, cardCode);
1037
+ break;
1038
+ } catch {
1039
+ return session.text(".convert-access-failed");
1040
+ }
1007
1041
  case "konamiid":
1008
1042
  cardCode = konamiIdToUid(cardCode);
1009
1043
  break;
@@ -1234,7 +1268,8 @@ async function showCardMenu(ctx, session, card2, cardService, serverService, use
1234
1268
  case "official":
1235
1269
  break;
1236
1270
  }
1237
- if (await cardService.getCardByCode(cardCode) != null) return session.text(".duplicate");
1271
+ if (await cardService.getCardByCode(cardCode) != null)
1272
+ return session.text(".menu-1-error-duplicate");
1238
1273
  let defaultServerId = card2.defaultServerId;
1239
1274
  if (cardType === "official") {
1240
1275
  defaultServerId = await ensureOfficialServerForUser(
@@ -1273,9 +1308,7 @@ async function showCardMenu(ctx, session, card2, cardService, serverService, use
1273
1308
  return session.text(".menu-4-success");
1274
1309
  }
1275
1310
  if (selectNum === 5) {
1276
- const userRes = await serverService.getServersByUid(uid);
1277
- const channelRes = await serverService.getServersByCid(cid);
1278
- const res = channelRes.concat(userRes);
1311
+ const res = await serverService.getSelectableServers(uid, cid);
1279
1312
  let serverListMsg = "";
1280
1313
  for (let i = 0; i < res.length; i++) {
1281
1314
  if (res[i].id === userDefaultServerId) {
@@ -2917,10 +2950,10 @@ function apply6(ctx, config) {
2917
2950
  __name(apply6, "apply");
2918
2951
 
2919
2952
  // src/core/locales/en-US.yml
2920
- var en_US_default3 = { _config: { $desc: "Core Module Settings", adminUsers: "**Plugin Admin** User ID (use inspect command to get)", guildNameCards: "**Group Card** Name List", maoServerUrl: "**Mao Server** URL address", official_support_url: "**Official Support** URL address" }, commands: { maintain: { description: "Switch to maintenance mode", messages: { "no-auth": "<p>You don't have permission to use this feature~</p>", start: "<p>Entering maintenance mode</p>", "success-start": "<p>Successfully switched to maintenance mode</p>", stop: "<p>Exiting maintenance mode</p>", "success-stop": "<p>Successfully exited maintenance mode</p>" } }, timeout: "Noah didn't wait for your reply, please try again!", noah: { help: { description: "Show Noah help information", messages: { content: "<p>Guide:</p>\nhttps://docs.logthm.cn/noah" } } }, locale: { description: "Set language", messages: { "no-auth": "<p>Only group admins can use this feature~</p>", "invalid-select": "<p>No such option!</p>", quit: "<p>Quit!</p>", "reset-channel": "<p>Reset successfully!</p>", "reset-user": "<p>Reset successfully!</p>", success: "<p>Set successfully!</p>", "set-channel": "<p>Select the default language for group chats:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. Quit</p>", "set-user": "<p>Select the language you use:</p>\n<p>1. 中文</p>\n<p>2. English</p>\n<p>q. Quit</p>" } }, bind: { description: "Bind card", messages: { prompt: "<p>Please enter your card number:</p>", "invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", name: "<p>Received! Please give this card a name, no spaces allowed:</p>", invalid_name: "<p>No spaces allowed! Please try again o(一︿一+)o</p>", success: "<p>Bound successfully, your card information is as follows:</p>\n<p>[{cardName}]</p>\n<p>{cardCode}</p>" } }, card: { description: "Manage cards", options: { detail: "Show detailed card information" }, messages: { "invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", "invalid-select": "<p>No such option!</p>", "lookup-error": "Lookup failed: {message}", "lookup-error-unknown": "Lookup failed: unknown error", "menu-select": "<p>[Card Management]</p>\n{card_list}\n<p>0. Add new card</p>\n<p>Please enter the serial number:</p>", menu: "<p>[{name}]</p>\n<p>1. Set as default card</p>\n<p>2. View or modify card number</p>\n<p>3. Delete the card</p>\n<p>4. Rename the card</p>\n<p>5. Bind the card to a specified server</p>\n<p>0. Return to card selection</p>\n<p>Please enter the serial number:</p>", "menu-has-bound-server": "<p>[{name}]</p>\n<p>Bound server: {defaultServerName}</p>\n<p>1. Set as default card</p>\n<p>2. View or modify card number</p>\n<p>3. Delete the card</p>\n<p>4. Rename the card</p>\n<p>5. Bind the card to a specified server</p>\n<p>0. Return to card selection</p>\n<p>Please enter the serial number:</p>", "menu-1-success": "<p>Set successfully!</p>", "menu-1-error-duplicate": "<p>The selected card is the same as the old default card!</p>", "menu-2-prompt": "<p>Card number: {code}</p>\n<p>Please enter the new card number:</p>\n<p>Enter 0 to return</p>", "menu-2-success": "<p>Modified successfully!</p>", "menu-2-error-invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", "menu-3-success": "<p>Deleted successfully!</p>", "menu-4-prompt": "<p>Enter a new name, no spaces allowed:</p>", "menu-4-error-invalid-name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "menu-4-success": "<p>Modified successfully!</p>", "menu-5": "{server_list}\n<p>Please enter the serial number:</p>", "menu-5-success": "<p>Set successfully!</p>", "menu-5-error-duplicate": "<p>The selected server is the same as the old default server!</p>" } }, server: { description: "Manage servers", messages: { "no-channel": "<p>Please use this feature in group chats~</p>", "invalid-select": "<p>No such option!</p>", "no-auth": "<p>Only group admins can use this feature~</p>", "admin-menu-select": "<p>[Group server management]</p>\n{server_list}\n<p>0. Add new server</p>\n<p>Please enter the serial number:</p>", "menu-select": "<p>[Server management]</p>\n{server_list}\n<p>0. Add new server</p>\n<p>Please enter the serial number:</p>", menu: "<p>[{name}]</p>\n<p>Type: {type}</p>\n<p>1. Set as default server</p>\n<p>2. View or modify url</p>\n<p>3. Delete the server</p>\n<p>4. Rename the server</p>\n<p>0. Return to server selection</p>\n<p>Please enter the serial number:</p>", "menu-1-success": "<p>Set successfully!</p>", "menu-1-error-duplicate": "<p>The selected server is the same as the old default server!</p>", "menu-2-prompt": "<p>The server's url: {baseUrl}</p>\n<p>Please enter the new server url:</p>\n<p>Enter 0 to return</p>", "menu-2-success": "<p>Modified successfully!</p>", "menu-2-invalid-url": "<p>Invalid URL format! Please enter a valid url address.</p>", "menu-2-too-many-attempts": "<p>Too many attempts. Operation cancelled.</p>", "menu-3-success": "<p>Deleted successfully!</p>", "menu-4-prompt": "<p>Enter a new name, no spaces allowed:</p>", "menu-4-error-invalid-name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "menu-4-success": "<p>Modified successfully!</p>", "add-type": "<p>Please select the server type:</p>\n{server_type_list}", "add-url": "<p>Please enter the server url:</p>", "add-invalid-url": "<p>Invalid URL format! Please enter a valid url address.</p>", "add-too-many-attempts": "<p>Too many attempts. Operation cancelled.</p>", "add-name": "<p>Received! Please give the server a name, no spaces allowed:</p>", "add-invalid_name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "add-success": "<p>Added successfully, the server information is as follows:</p>\n<p>[{serverName}]</p>\n<p>Type: {serverType}</p>" } } } };
2953
+ var en_US_default3 = { _config: { $desc: "Core Module Settings", adminUsers: "**Plugin Admin** User ID (use inspect command to get)", guildNameCards: "**Group Card** Name List", maoServerUrl: "**Mao Server** URL address", official_support_url: "**Official Support** URL address" }, commands: { maintain: { description: "Switch to maintenance mode", messages: { "no-auth": "<p>You don't have permission to use this feature~</p>", start: "<p>Entering maintenance mode</p>", "success-start": "<p>Successfully switched to maintenance mode</p>", stop: "<p>Exiting maintenance mode</p>", "success-stop": "<p>Successfully exited maintenance mode</p>" } }, timeout: "Noah didn't wait for your reply, please try again!", noah: { help: { description: "Show Noah help information", messages: { content: "<p>Guide:</p>\nhttps://docs.logthm.cn/noah" } } }, locale: { description: "Set language", messages: { "no-auth": "<p>Only group admins can use this feature~</p>", "invalid-select": "<p>No such option!</p>", quit: "<p>Quit!</p>", "reset-channel": "<p>Reset successfully!</p>", "reset-user": "<p>Reset successfully!</p>", success: "<p>Set successfully!</p>", "set-channel": "<p>Select the default language for group chats:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. Quit</p>", "set-user": "<p>Select the language you use:</p>\n<p>1. 中文</p>\n<p>2. English</p>\n<p>q. Quit</p>" } }, bind: { description: "Bind card", messages: { prompt: "<p>Please enter your card number:</p>", "convert-access-failed": "<p>Failed to convert Access Code, please use 16-digit card code instead.</p>", "invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", name: "<p>Received! Please give this card a name, no spaces allowed:</p>", invalid_name: "<p>No spaces allowed! Please try again o(一︿一+)o</p>", success: "<p>Bound successfully, your card information is as follows:</p>\n<p>[{cardName}]</p>\n<p>{cardCode}</p>" } }, card: { description: "Manage cards", options: { detail: "Show detailed card information" }, messages: { "invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", "invalid-select": "<p>No such option!</p>", "lookup-error": "Lookup failed: {message}", "lookup-error-unknown": "Lookup failed: unknown error", "menu-select": "<p>[Card Management]</p>\n{card_list}\n<p>0. Add new card</p>\n<p>Please enter the serial number:</p>", menu: "<p>[{name}]</p>\n<p>1. Set as default card</p>\n<p>2. View or modify card number</p>\n<p>3. Delete the card</p>\n<p>4. Rename the card</p>\n<p>5. Bind the card to a specified server</p>\n<p>0. Return to card selection</p>\n<p>Please enter the serial number:</p>", "menu-has-bound-server": "<p>[{name}]</p>\n<p>Bound server: {defaultServerName}</p>\n<p>1. Set as default card</p>\n<p>2. View or modify card number</p>\n<p>3. Delete the card</p>\n<p>4. Rename the card</p>\n<p>5. Bind the card to a specified server</p>\n<p>0. Return to card selection</p>\n<p>Please enter the serial number:</p>", "menu-1-success": "<p>Set successfully!</p>", "menu-1-error-duplicate": "<p>The selected card is the same as the old default card!</p>", "menu-2-prompt": "<p>Card number: {code}</p>\n<p>Please enter the new card number:</p>\n<p>Enter 0 to return</p>", "menu-2-success": "<p>Modified successfully!</p>", "menu-2-error-invalid-code": "<p>The card number is incorrect, if you don't remember it, go to the arcade to check it~</p>", "menu-3-success": "<p>Deleted successfully!</p>", "menu-4-prompt": "<p>Enter a new name, no spaces allowed:</p>", "menu-4-error-invalid-name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "menu-4-success": "<p>Modified successfully!</p>", "menu-5": "{server_list}\n<p>Please enter the serial number:</p>", "menu-5-success": "<p>Set successfully!</p>", "menu-5-error-duplicate": "<p>The selected server is the same as the old default server!</p>" } }, server: { description: "Manage servers", messages: { "no-channel": "<p>Please use this feature in group chats~</p>", "invalid-select": "<p>No such option!</p>", "no-auth": "<p>Only group admins can use this feature~</p>", "admin-menu-select": "<p>[Group server management]</p>\n{server_list}\n<p>0. Add new server</p>\n<p>Please enter the serial number:</p>", "menu-select": "<p>[Server management]</p>\n{server_list}\n<p>0. Add new server</p>\n<p>Please enter the serial number:</p>", menu: "<p>[{name}]</p>\n<p>Type: {type}</p>\n<p>1. Set as default server</p>\n<p>2. View or modify url</p>\n<p>3. Delete the server</p>\n<p>4. Rename the server</p>\n<p>0. Return to server selection</p>\n<p>Please enter the serial number:</p>", "menu-1-success": "<p>Set successfully!</p>", "menu-1-error-duplicate": "<p>The selected server is the same as the old default server!</p>", "menu-2-prompt": "<p>The server's url: {baseUrl}</p>\n<p>Please enter the new server url:</p>\n<p>Enter 0 to return</p>", "menu-2-success": "<p>Modified successfully!</p>", "menu-2-invalid-url": "<p>Invalid URL format! Please enter a valid url address.</p>", "menu-2-too-many-attempts": "<p>Too many attempts. Operation cancelled.</p>", "menu-3-success": "<p>Deleted successfully!</p>", "menu-4-prompt": "<p>Enter a new name, no spaces allowed:</p>", "menu-4-error-invalid-name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "menu-4-success": "<p>Modified successfully!</p>", "add-type": "<p>Please select the server type:</p>\n{server_type_list}", "add-url": "<p>Please enter the server url:</p>", "add-invalid-url": "<p>Invalid URL format! Please enter a valid url address.</p>", "add-too-many-attempts": "<p>Too many attempts. Operation cancelled.</p>", "add-name": "<p>Received! Please give the server a name, no spaces allowed:</p>", "add-invalid_name": "<p>No spaces allowed! Please try again o(一︿一+)o</p>", "add-success": "<p>Added successfully, the server information is as follows:</p>\n<p>[{serverName}]</p>\n<p>Type: {serverType}</p>" } } } };
2921
2954
 
2922
2955
  // src/core/locales/zh-CN.yml
2923
- var zh_CN_default3 = { _config: { $desc: "Core 模块设置", adminUsers: "**插件管理员** 的用户id (使用 inspect 指令获取)", guildNameCards: "**群聊卡片** 的名称列表", maoServerUrl: "**猫网服务器** 的 URL 地址", official_support_url: "**官方支持** 的 URL 地址" }, commands: { maintain: { description: "切换到维护模式", messages: { "no-auth": "<p>你没有权限使用本功能哦~</p>", start: "<p>正在进入维护模式</p>", "success-start": "<p>成功切换到维护模式</p>", stop: "<p>正在退出维护模式</p>", "success-stop": "<p>成功退出维护模式</p>" } }, timeout: "Noah 没等到你的回复,请重试!", noah: { help: { description: "显示 Noah 帮助信息", messages: { content: "<p>使用文档:</p>\nhttps://docs.logthm.cn/noah" } } }, locale: { description: "设置语言", messages: { "no-auth": "<p>只有群管理员能使用本功能哦~</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>", "reset-channel": "<p>重置成功!</p>", "reset-user": "<p>重置成功!</p>", success: "<p>设置成功!</p>", "set-channel": "<p>选择群聊默认使用的语言:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. 退出</p>", "set-user": "<p>选择你使用的语言:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. 退出</p>" } }, bind: { description: "绑定卡片", messages: { prompt: "<p>请输入你的卡号:</p>", "invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", name: "<p>收到!为这张卡取一个名字吧,不要带空格哦:</p>", invalid_name: "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", success: "<p>绑好啦,你的卡片信息如下:</p>\n<p>[{cardName}]</p>\n<p>{cardCode}</p>" } }, card: { description: "管理卡片", options: { detail: "显示卡片详细信息" }, messages: { "invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>", "lookup-error": "查询失败: {message}", "lookup-error-unknown": "查询失败: 未知错误", "menu-select": "<p>[卡片管理]</p>\n{card_list}\n<p>0. 添加新卡片</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", menu: "<p>[{name}]</p>\n<p>1. 设为默认卡片</p>\n<p>2. 查看或修改卡号</p>\n<p>3. 删除该卡</p>\n<p>4. 重命名该卡片</p>\n<p>5. 将卡片与指定服务器绑定</p>\n<p>0. 返回卡片选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-has-bound-server": "<p>[{name}]</p>\n<p>已绑定服务器:{defaultServerName}</p>\n<p>1. 设为默认卡片</p>\n<p>2. 查看或修改卡号</p>\n<p>3. 删除该卡</p>\n<p>4. 重命名该卡片</p>\n<p>5. 将卡片与指定服务器绑定</p>\n<p>0. 返回卡片选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-1-success": "<p>设置成功!</p>", "menu-1-error-duplicate": "<p>选择的卡片与旧的默认卡片相同!</p>", "menu-2-prompt": "<p>卡号:{code}</p>\n<p>请输入新的卡号:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-2-success": "<p>修改成功!</p>", "menu-2-error-invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", "menu-3-success": "<p>已删除!</p>", "menu-4-prompt": "<p>输入一个新名字,不要带空格哦:</p>\n<p>q. 退出</p>", "menu-4-error-invalid-name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "menu-4-success": "<p>修改成功!</p>", "menu-5": "<p>请选择一个服务器:</p>\n{server_list}\n<p>q. 退出</p>", "menu-5-success": "<p>设置成功!</p>", "menu-5-error-duplicate": "<p>选择的服务器与旧的默认服务器相同!</p>" } }, server: { description: "管理服务器", messages: { "no-channel": "<p>请在群聊中使用本功能哦~</p>", "invalid-select": "<p>没有该选项!</p>", "no-auth": "<p>只有群管理员能使用本功能哦~</p>", quit: "<p>已退出~</p>", "admin-menu-select": "<p>[群聊服务器管理]</p>\n{server_list}\n<p>0. 添加新服务器</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-select": "<p>[服务器管理]</p>\n{server_list}\n<p>0. 添加新服务器</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", menu: "<p>[{name}]</p>\n<p>类型:{type}</p>\n<p>1. 设为默认服务器</p>\n<p>2. 查看或修改 url</p>\n<p>3. 删除该服务器</p>\n<p>4. 重命名该服务器</p>\n<p>0. 返回服务器选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-1-success": "<p>设置成功!</p>", "menu-1-error-duplicate": "<p>选择的服务器与旧的默认服务器相同!</p>", "menu-2-prompt": "<p>该服务器的 url:{baseUrl}</p>\n<p>请输入新的服务器 url:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-2-success": "<p>修改成功!</p>", "menu-2-invalid-url": "<p>URL 格式不正确!请输入有效的 url 地址。</p>", "menu-2-too-many-attempts": "<p>尝试次数过多,操作已取消。</p>", "menu-3-success": "<p>已删除!</p>", "menu-4-prompt": "<p>输入一个新名字,不要带空格哦:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-4-error-invalid-name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "menu-4-success": "<p>修改成功!</p>", "add-type": "<p>请选择服务器的类型:</p>\n{server_type_list}\n<p>q. 退出</p>", "add-url": "<p>请输入服务器的 url:</p>\n<p>q. 退出</p>", "add-invalid-url": "<p>URL 格式不正确!请输入有效的 url 地址。</p>", "add-too-many-attempts": "<p>尝试次数过多,操作已取消。</p>", "add-name": "<p>收到!为服务器取一个名字吧,不要带空格哦:</p>\n<p>q. 退出</p>", "add-invalid_name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "add-success": "<p>添加成功啦,服务器信息如下:</p>\n<p>[{serverName}]</p>\n<p>类型:{serverType}</p>" } } } };
2956
+ var zh_CN_default3 = { _config: { $desc: "Core 模块设置", adminUsers: "**插件管理员** 的用户id (使用 inspect 指令获取)", guildNameCards: "**群聊卡片** 的名称列表", maoServerUrl: "**猫网服务器** 的 URL 地址", official_support_url: "**官方支持** 的 URL 地址" }, commands: { maintain: { description: "切换到维护模式", messages: { "no-auth": "<p>你没有权限使用本功能哦~</p>", start: "<p>正在进入维护模式</p>", "success-start": "<p>成功切换到维护模式</p>", stop: "<p>正在退出维护模式</p>", "success-stop": "<p>成功退出维护模式</p>" } }, timeout: "Noah 没等到你的回复,请重试!", noah: { help: { description: "显示 Noah 帮助信息", messages: { content: "<p>使用文档:</p>\nhttps://docs.logthm.cn/noah" } } }, locale: { description: "设置语言", messages: { "no-auth": "<p>只有群管理员能使用本功能哦~</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>", "reset-channel": "<p>重置成功!</p>", "reset-user": "<p>重置成功!</p>", success: "<p>设置成功!</p>", "set-channel": "<p>选择群聊默认使用的语言:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. 退出</p>", "set-user": "<p>选择你使用的语言:</p>\n<p>1. 简体中文</p>\n<p>2. English</p>\n<p>q. 退出</p>" } }, bind: { description: "绑定卡片", messages: { prompt: "<p>请输入你的卡号:</p>", "convert-access-failed": "<p>转换 Access Code 失败,请使用 16 位卡号进行绑定。</p>", "invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", name: "<p>收到!为这张卡取一个名字吧,不要带空格哦:</p>", invalid_name: "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", success: "<p>绑好啦,你的卡片信息如下:</p>\n<p>[{cardName}]</p>\n<p>{cardCode}</p>" } }, card: { description: "管理卡片", options: { detail: "显示卡片详细信息" }, messages: { "invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>", "lookup-error": "查询失败: {message}", "lookup-error-unknown": "查询失败: 未知错误", "menu-select": "<p>[卡片管理]</p>\n{card_list}\n<p>0. 添加新卡片</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", menu: "<p>[{name}]</p>\n<p>1. 设为默认卡片</p>\n<p>2. 查看或修改卡号</p>\n<p>3. 删除该卡</p>\n<p>4. 重命名该卡片</p>\n<p>5. 将卡片与指定服务器绑定</p>\n<p>0. 返回卡片选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-has-bound-server": "<p>[{name}]</p>\n<p>已绑定服务器:{defaultServerName}</p>\n<p>1. 设为默认卡片</p>\n<p>2. 查看或修改卡号</p>\n<p>3. 删除该卡</p>\n<p>4. 重命名该卡片</p>\n<p>5. 将卡片与指定服务器绑定</p>\n<p>0. 返回卡片选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-1-success": "<p>设置成功!</p>", "menu-1-error-duplicate": "<p>选择的卡片与旧的默认卡片相同!</p>", "menu-2-prompt": "<p>卡号:{code}</p>\n<p>请输入新的卡号:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-2-success": "<p>修改成功!</p>", "menu-2-error-invalid-code": "<p>卡号不对哟,不记得的话去机台刷一下吧~</p>", "menu-3-success": "<p>已删除!</p>", "menu-4-prompt": "<p>输入一个新名字,不要带空格哦:</p>\n<p>q. 退出</p>", "menu-4-error-invalid-name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "menu-4-success": "<p>修改成功!</p>", "menu-5": "<p>请选择一个服务器:</p>\n{server_list}\n<p>q. 退出</p>", "menu-5-success": "<p>设置成功!</p>", "menu-5-error-duplicate": "<p>选择的服务器与旧的默认服务器相同!</p>" } }, server: { description: "管理服务器", messages: { "no-channel": "<p>请在群聊中使用本功能哦~</p>", "invalid-select": "<p>没有该选项!</p>", "no-auth": "<p>只有群管理员能使用本功能哦~</p>", quit: "<p>已退出~</p>", "admin-menu-select": "<p>[群聊服务器管理]</p>\n{server_list}\n<p>0. 添加新服务器</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-select": "<p>[服务器管理]</p>\n{server_list}\n<p>0. 添加新服务器</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", menu: "<p>[{name}]</p>\n<p>类型:{type}</p>\n<p>1. 设为默认服务器</p>\n<p>2. 查看或修改 url</p>\n<p>3. 删除该服务器</p>\n<p>4. 重命名该服务器</p>\n<p>0. 返回服务器选择</p>\n<p>q. 退出菜单</p>\n<p>请输入序号:</p>", "menu-1-success": "<p>设置成功!</p>", "menu-1-error-duplicate": "<p>选择的服务器与旧的默认服务器相同!</p>", "menu-2-prompt": "<p>该服务器的 url:{baseUrl}</p>\n<p>请输入新的服务器 url:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-2-success": "<p>修改成功!</p>", "menu-2-invalid-url": "<p>URL 格式不正确!请输入有效的 url 地址。</p>", "menu-2-too-many-attempts": "<p>尝试次数过多,操作已取消。</p>", "menu-3-success": "<p>已删除!</p>", "menu-4-prompt": "<p>输入一个新名字,不要带空格哦:</p>\n<p>0. 返回</p>\n<p>q. 退出</p>", "menu-4-error-invalid-name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "menu-4-success": "<p>修改成功!</p>", "add-type": "<p>请选择服务器的类型:</p>\n{server_type_list}\n<p>q. 退出</p>", "add-url": "<p>请输入服务器的 url:</p>\n<p>q. 退出</p>", "add-invalid-url": "<p>URL 格式不正确!请输入有效的 url 地址。</p>", "add-too-many-attempts": "<p>尝试次数过多,操作已取消。</p>", "add-name": "<p>收到!为服务器取一个名字吧,不要带空格哦:</p>\n<p>q. 退出</p>", "add-invalid_name": "<p>名字不要带空格!重来重来 o(一︿一+)o</p>", "add-success": "<p>添加成功啦,服务器信息如下:</p>\n<p>[{serverName}]</p>\n<p>类型:{serverType}</p>" } } } };
2924
2957
 
2925
2958
  // src/core/index.ts
2926
2959
  var name7 = "Noah-Core";
@@ -3109,14 +3142,14 @@ var GRADE_FACTOR_SCALES = {
3109
3142
  var CLEAR_FACTOR_SCALES = {
3110
3143
  "S-PUC": 110,
3111
3144
  PUC: 110,
3112
- UC: 105,
3145
+ UC: 106,
3113
3146
  MC: 104,
3114
3147
  HC: 102,
3115
3148
  NC: 100,
3116
3149
  PLAYED: 50,
3117
3150
  "NO PLAY": 50
3118
3151
  };
3119
- var VOLFORCE_BASE_DENOMINATOR = BigInt(5e5) * BigInt(FACTOR_SCALE) * BigInt(FACTOR_SCALE);
3152
+ var VOLFORCE_BASE_DENOMINATOR = BigInt(5e5) * BigInt(FACTOR_SCALE) * BigInt(FACTOR_SCALE) * BigInt(10);
3120
3153
  function calculateVolforce(raw_score) {
3121
3154
  const score = raw_score.music.score;
3122
3155
  const level = raw_score.music.music_diff;
@@ -3139,7 +3172,8 @@ function calculateVolforceIntValue(level, score, grade, clearType) {
3139
3172
  }
3140
3173
  __name(calculateVolforceIntValue, "calculateVolforceIntValue");
3141
3174
  function calculateVolforceInt(level, score, grade, clearType) {
3142
- const numerator = BigInt(level) * BigInt(score) * BigInt(GRADE_FACTOR_SCALES[grade]) * BigInt(CLEAR_FACTOR_SCALES[clearType]);
3175
+ const levelScaled = Math.round(level * 10);
3176
+ const numerator = BigInt(levelScaled) * BigInt(score) * BigInt(GRADE_FACTOR_SCALES[grade]) * BigInt(CLEAR_FACTOR_SCALES[clearType]);
3143
3177
  return numerator / VOLFORCE_BASE_DENOMINATOR;
3144
3178
  }
3145
3179
  __name(calculateVolforceInt, "calculateVolforceInt");
@@ -3160,6 +3194,27 @@ var SCORE_GRADE_MAP = [
3160
3194
  ];
3161
3195
  var VF_INT_MULTIPLIER = 20;
3162
3196
  var VF_INPUT_PRECISION = 6;
3197
+ var LEVEL_SCALE = 10;
3198
+ function scaleLevel(level) {
3199
+ return Math.round(level * LEVEL_SCALE);
3200
+ }
3201
+ __name(scaleLevel, "scaleLevel");
3202
+ function unscaleLevel(levelScaled) {
3203
+ return levelScaled / LEVEL_SCALE;
3204
+ }
3205
+ __name(unscaleLevel, "unscaleLevel");
3206
+ function getAllValidLevels() {
3207
+ const levels = [];
3208
+ for (let i = 1; i <= 16; i++) {
3209
+ levels.push(i);
3210
+ }
3211
+ levels.push(17, 17.5);
3212
+ for (let levelScaled = 180; levelScaled <= 209; levelScaled++) {
3213
+ levels.push(unscaleLevel(levelScaled));
3214
+ }
3215
+ return levels;
3216
+ }
3217
+ __name(getAllValidLevels, "getAllValidLevels");
3163
3218
  function normalizeVfInput(value) {
3164
3219
  return Number(value.toFixed(VF_INPUT_PRECISION));
3165
3220
  }
@@ -3177,7 +3232,8 @@ function floorVfInt(value) {
3177
3232
  }
3178
3233
  __name(floorVfInt, "floorVfInt");
3179
3234
  function getVolforceMultiplier(level, grade, clearType) {
3180
- return BigInt(level) * BigInt(getGradeFactorScale(grade)) * BigInt(getClearFactorScale(clearType));
3235
+ const levelScaled = Math.round(level * 10);
3236
+ return BigInt(levelScaled) * BigInt(getGradeFactorScale(grade)) * BigInt(getClearFactorScale(clearType));
3181
3237
  }
3182
3238
  __name(getVolforceMultiplier, "getVolforceMultiplier");
3183
3239
  function getMinScoreForVfInt(multiplier, vfInt) {
@@ -3286,8 +3342,9 @@ function getClearTypeRange(clearType) {
3286
3342
  }
3287
3343
  __name(getClearTypeRange, "getClearTypeRange");
3288
3344
  function getLevelRange(level) {
3289
- if (!level) return Array.from({ length: 20 }, (_, i) => i + 1);
3290
- return Array.isArray(level) ? level : [level];
3345
+ if (!level) return getAllValidLevels();
3346
+ const raw = Array.isArray(level) ? level : [level];
3347
+ return raw.map((l) => unscaleLevel(scaleLevel(l)));
3291
3348
  }
3292
3349
  __name(getLevelRange, "getLevelRange");
3293
3350
  function generateVFLookupTable() {
@@ -3303,7 +3360,8 @@ function generateVFLookupTable() {
3303
3360
  "PLAYED",
3304
3361
  "NO PLAY"
3305
3362
  ];
3306
- for (let level = 1; level <= 20; level++) {
3363
+ for (const level of getAllValidLevels()) {
3364
+ const levelScaled = scaleLevel(level);
3307
3365
  for (const grade of allGrades) {
3308
3366
  const gradeMapping = SCORE_GRADE_MAP.find((m) => m.grade === grade);
3309
3367
  if (!gradeMapping) {
@@ -3337,12 +3395,13 @@ function generateVFLookupTable() {
3337
3395
  if (!possibleClearTypes.includes(clearType)) {
3338
3396
  continue;
3339
3397
  }
3340
- const vf2 = calculateVolforce2(level, score, grade, clearType);
3341
- const key = `${level}-${grade}-${clearType}-${vf2}`;
3398
+ const vfInt = calculateVolforceIntValue(level, score, grade, clearType);
3399
+ const vf2 = vfInt / VF_INT_MULTIPLIER;
3400
+ const key = `${levelScaled}-${grade}-${clearType}-${vfInt}`;
3342
3401
  const existing = lookupTable.get(key);
3343
3402
  if (!existing || score < existing.score) {
3344
3403
  lookupTable.set(key, {
3345
- level,
3404
+ level: unscaleLevel(levelScaled),
3346
3405
  score,
3347
3406
  grade,
3348
3407
  clearType,
@@ -3386,13 +3445,14 @@ function generateQueryResults(params) {
3386
3445
  const lookupTable = getVFLookupTable();
3387
3446
  const results = [];
3388
3447
  const levels = getLevelRange(params.level);
3448
+ const levelScaleSet = new Set(levels.map(scaleLevel));
3389
3449
  const clearTypes = getClearTypeRange(params.clearType);
3390
3450
  const grades = getGradeRange(params.grade);
3391
3451
  if (params.vf) {
3392
3452
  const vfValues = getVfRange(params.vf);
3393
3453
  const vfSet = new Set(vfValues);
3394
3454
  for (const result of lookupTable.values()) {
3395
- if (levels.includes(result.level) && clearTypes.includes(result.clearType) && grades.includes(result.grade) && vfSet.has(result.vf)) {
3455
+ if (levelScaleSet.has(scaleLevel(result.level)) && clearTypes.includes(result.clearType) && grades.includes(result.grade) && vfSet.has(result.vf)) {
3396
3456
  results.push(result);
3397
3457
  }
3398
3458
  }
@@ -3490,7 +3550,7 @@ function generateQueryResults(params) {
3490
3550
  }
3491
3551
  } else {
3492
3552
  for (const result of lookupTable.values()) {
3493
- if (levels.includes(result.level) && clearTypes.includes(result.clearType) && grades.includes(result.grade)) {
3553
+ if (levelScaleSet.has(scaleLevel(result.level)) && clearTypes.includes(result.clearType) && grades.includes(result.grade)) {
3494
3554
  results.push(result);
3495
3555
  }
3496
3556
  }
@@ -3518,6 +3578,14 @@ var SDVXDrawer = class extends BaseDrawer {
3518
3578
  static {
3519
3579
  __name(this, "SDVXDrawer");
3520
3580
  }
3581
+ /**
3582
+ * Format difficulty level text.
3583
+ * If it's an integer like 17 / 20, keep trailing ".0" (e.g. "17.0").
3584
+ */
3585
+ formatDifficultyLevel(level) {
3586
+ if (!Number.isFinite(level)) return String(level);
3587
+ return Number.isInteger(level) ? level.toFixed(1) : String(level);
3588
+ }
3521
3589
  /**
3522
3590
  * 生成SDVX最近成绩图像
3523
3591
  * @param options - 图像生成配置选项
@@ -3709,7 +3777,7 @@ var SDVXDrawer = class extends BaseDrawer {
3709
3777
  ctx.drawImage(gradeImage, 139, 151);
3710
3778
  const playTypeImage = playTypeImages[options.score.music.clear_type];
3711
3779
  ctx.drawImage(playTypeImage, 139, 178);
3712
- const levelStr = options.score.music.music_diff.toString();
3780
+ const levelStr = this.formatDifficultyLevel(options.score.music.music_diff);
3713
3781
  ctx.fillStyle = "#0230A5";
3714
3782
  ctx.textBaseline = "alphabetic";
3715
3783
  ctx.textAlign = "left";
@@ -3978,7 +4046,7 @@ var SDVXDrawer = class extends BaseDrawer {
3978
4046
  ctx.font = '24px "Fredoka One"';
3979
4047
  ctx.textAlign = "right";
3980
4048
  ctx.textBaseline = "alphabetic";
3981
- ctx.fillText(score.music.music_diff.toString(), x + 248, y + 67);
4049
+ ctx.fillText(this.formatDifficultyLevel(score.music.music_diff), x + 248, y + 67);
3982
4050
  ctx.restore();
3983
4051
  const scoreText = score.music.score.toString();
3984
4052
  const scoreStartX = x + 381;
@@ -4414,7 +4482,7 @@ var SDVXDrawer = class extends BaseDrawer {
4414
4482
  const rowHeight = rowHeights.get(level) || baseCellHeight;
4415
4483
  const x = padding;
4416
4484
  const y = levelY + rowHeight / 2;
4417
- ctx.fillText(level.toString(), x + levelColumnWidth / 2, y);
4485
+ ctx.fillText(this.formatDifficultyLevel(level), x + levelColumnWidth / 2, y);
4418
4486
  levelY += rowHeight;
4419
4487
  }
4420
4488
  ctx.strokeStyle = "#444444";
@@ -4886,20 +4954,72 @@ function parseNumberWithSuffix(str) {
4886
4954
  return num;
4887
4955
  }
4888
4956
  __name(parseNumberWithSuffix, "parseNumberWithSuffix");
4957
+ function isValidLevel(level) {
4958
+ if (level < 1 || level > 20.9) {
4959
+ return false;
4960
+ }
4961
+ const integerPart = Math.floor(level);
4962
+ const decimalPart = level - integerPart;
4963
+ if (integerPart >= 1 && integerPart <= 16) {
4964
+ return decimalPart === 0;
4965
+ }
4966
+ if (integerPart === 17) {
4967
+ return decimalPart === 0 || decimalPart === 0.5;
4968
+ }
4969
+ if (integerPart >= 18 && integerPart <= 20) {
4970
+ const tenths = Math.round(decimalPart * 10);
4971
+ return Math.abs(decimalPart * 10 - tenths) < 1e-4 && tenths >= 0 && tenths <= 9;
4972
+ }
4973
+ return false;
4974
+ }
4975
+ __name(isValidLevel, "isValidLevel");
4976
+ function generateValidLevelRange(start, end) {
4977
+ const levels = [];
4978
+ const startInt = Math.floor(start);
4979
+ const endInt = Math.floor(end);
4980
+ const startTenth = Math.round((start - startInt) * 10);
4981
+ const endTenth = Math.round((end - endInt) * 10);
4982
+ for (let intPart = startInt; intPart <= endInt; intPart++) {
4983
+ if (intPart >= 1 && intPart <= 16) {
4984
+ if (intPart === startInt && startTenth > 0 || intPart === endInt && endTenth > 0) {
4985
+ continue;
4986
+ }
4987
+ levels.push(intPart);
4988
+ } else if (intPart === 17) {
4989
+ const currentStartTenth = intPart === startInt ? startTenth : 0;
4990
+ const currentEndTenth = intPart === endInt ? endTenth : 5;
4991
+ if (currentStartTenth <= 0 && currentEndTenth >= 0) {
4992
+ levels.push(17);
4993
+ }
4994
+ if (currentStartTenth <= 5 && currentEndTenth >= 5) {
4995
+ levels.push(17.5);
4996
+ }
4997
+ } else if (intPart >= 18 && intPart <= 20) {
4998
+ const currentStartTenth = intPart === startInt ? startTenth : 0;
4999
+ const currentEndTenth = intPart === endInt ? endTenth : 9;
5000
+ for (let tenth = currentStartTenth; tenth <= currentEndTenth && tenth <= 9; tenth++) {
5001
+ levels.push(intPart + tenth / 10);
5002
+ }
5003
+ }
5004
+ }
5005
+ return levels;
5006
+ }
5007
+ __name(generateValidLevelRange, "generateValidLevelRange");
4889
5008
  function parseLevelRange(item) {
4890
- if (/^\d{1,2}-\d{1,2}$/.test(item)) {
4891
- const [start, end] = item.split("-").map(Number);
4892
- if (start >= 1 && end <= 20 && start <= end) {
4893
- const levels = [];
4894
- for (let i = start; i <= end; i++) {
4895
- levels.push(i);
5009
+ if (/^\d{1,2}(\.\d)?-\d{1,2}(\.\d)?$/.test(item)) {
5010
+ const [startRaw, endRaw] = item.split("-");
5011
+ const start = parseFloat(startRaw.includes(".") ? startRaw : startRaw + ".0");
5012
+ const end = parseFloat(endRaw.includes(".") ? endRaw : endRaw + ".0");
5013
+ if (start >= 1 && end <= 20.9 && start <= end) {
5014
+ if (!isValidLevel(start) || !isValidLevel(end)) {
5015
+ return null;
4896
5016
  }
4897
- return levels;
5017
+ return generateValidLevelRange(start, end);
4898
5018
  }
4899
5019
  }
4900
- if (/^\d{1,2}$/.test(item)) {
4901
- const level = parseInt(item, 10);
4902
- if (level >= 1 && level <= 20) {
5020
+ if (/^\d{1,2}(\.\d)?$/.test(item)) {
5021
+ const level = parseFloat(item.includes(".") ? item : item + ".0");
5022
+ if (isValidLevel(level)) {
4903
5023
  return [level];
4904
5024
  }
4905
5025
  }
@@ -5199,6 +5319,8 @@ function getDiffName(diffStr, infVer) {
5199
5319
  break;
5200
5320
  case "maximum":
5201
5321
  return "MXM";
5322
+ case "ultimate":
5323
+ return "ULT";
5202
5324
  }
5203
5325
  }
5204
5326
  __name(getDiffName, "getDiffName");
@@ -5229,6 +5351,8 @@ function getDiffFullName(diffStr, infVer) {
5229
5351
  break;
5230
5352
  case "maximum":
5231
5353
  return "MAXIMUM";
5354
+ case "ultimate":
5355
+ return "ULTIMATE";
5232
5356
  }
5233
5357
  }
5234
5358
  __name(getDiffFullName, "getDiffFullName");
@@ -5253,6 +5377,8 @@ function getDiffStringFromAbbr(diffAbbr) {
5253
5377
  return { diffStr: "infinite", infVer: 6 };
5254
5378
  case "MXM":
5255
5379
  return { diffStr: "maximum", infVer: null };
5380
+ case "ULT":
5381
+ return { diffStr: "ultimate", infVer: null };
5256
5382
  default:
5257
5383
  return null;
5258
5384
  }
@@ -5271,6 +5397,26 @@ var MusicService = class _MusicService {
5271
5397
  this.sdvx_data_url = config.sdvx_data_url;
5272
5398
  this.sdvx_search_url = config.sdvx_search_url;
5273
5399
  }
5400
+ parseDifnum(value) {
5401
+ if (typeof value === "number") {
5402
+ return Number.isFinite(value) ? value : 0;
5403
+ }
5404
+ if (typeof value === "string") {
5405
+ const parsed = Number.parseFloat(value.trim());
5406
+ return Number.isFinite(parsed) ? parsed : 0;
5407
+ }
5408
+ return 0;
5409
+ }
5410
+ normalizeMusicDifnum(music) {
5411
+ if (!music?.difficulty?.length) return music;
5412
+ return {
5413
+ ...music,
5414
+ difficulty: music.difficulty.map((d) => ({
5415
+ ...d,
5416
+ difnum: this.parseDifnum(d.difnum)
5417
+ }))
5418
+ };
5419
+ }
5274
5420
  /**
5275
5421
  * 获取 MusicService 实例
5276
5422
  * @param config - SDVX 配置对象
@@ -5299,7 +5445,11 @@ var MusicService = class _MusicService {
5299
5445
  { ids: numericIds },
5300
5446
  { baseURL: this.sdvx_data_url }
5301
5447
  );
5302
- results.push(...response);
5448
+ if (Array.isArray(response)) {
5449
+ results.push(
5450
+ ...response.map((m) => this.normalizeMusicDifnum(m))
5451
+ );
5452
+ }
5303
5453
  } catch (error) {
5304
5454
  console.warn("Failed to fetch music data for numeric IDs:", error);
5305
5455
  }
@@ -5312,7 +5462,7 @@ var MusicService = class _MusicService {
5312
5462
  { baseURL: this.sdvx_data_url }
5313
5463
  );
5314
5464
  const converted = this.convertExternalToStandard(response);
5315
- results.push(...converted);
5465
+ results.push(...converted.map((m) => this.normalizeMusicDifnum(m)));
5316
5466
  } catch (error) {
5317
5467
  console.warn("Failed to fetch music data for string IDs:", error);
5318
5468
  }
@@ -5334,7 +5484,7 @@ var MusicService = class _MusicService {
5334
5484
  inf_ver: data.difficulty[0]?.inf_ver || 0,
5335
5485
  difficulty: data.difficulty.map((diff) => ({
5336
5486
  difstr: diff.difstr,
5337
- difnum: diff.difnum,
5487
+ difnum: this.parseDifnum(diff.difnum),
5338
5488
  illustrator: diff.ext_illustrator,
5339
5489
  effected_by: diff.ext_effected_by,
5340
5490
  cover_url: diff.cover_url,
@@ -5365,6 +5515,32 @@ var MusicService = class _MusicService {
5365
5515
  return null;
5366
5516
  }
5367
5517
  }
5518
+ async extTitleToMid(ctx, titles) {
5519
+ try {
5520
+ const response = await ctx.http.post(
5521
+ `/external/title-mapping`,
5522
+ { titles },
5523
+ { baseURL: this.sdvx_data_url }
5524
+ );
5525
+ if (!Array.isArray(response)) {
5526
+ return [];
5527
+ }
5528
+ const mapping = new Map(
5529
+ response.map((item) => [
5530
+ item?.title?.toLowerCase(),
5531
+ Number.isInteger(item?.music_id) ? String(item.music_id) : null
5532
+ ])
5533
+ );
5534
+ const mids = [];
5535
+ for (const title of titles) {
5536
+ const mid = mapping.get(title.toLowerCase()) ?? null;
5537
+ mids.push({ title, mid });
5538
+ }
5539
+ return mids;
5540
+ } catch {
5541
+ return [];
5542
+ }
5543
+ }
5368
5544
  };
5369
5545
 
5370
5546
  // src/games/sdvx/commands/chart.ts
@@ -5819,7 +5995,7 @@ var SDVXService2 = class _SDVXService {
5819
5995
  }
5820
5996
  /**
5821
5997
  * Get the difficulty string based on the difficulty type number
5822
- * @param diffType Difficulty type (0-4)
5998
+ * @param diffType Difficulty type
5823
5999
  * @returns The difficulty string (novice, advanced, exhaust, infinite, maximum)
5824
6000
  */
5825
6001
  getDifficultyString(diffType) {
@@ -5851,6 +6027,38 @@ var SDVXService2 = class _SDVXService {
5851
6027
  }
5852
6028
  return match[1];
5853
6029
  }
6030
+ /**
6031
+ * 验证猫网 PIN 码
6032
+ * @param ctx - Koishi 上下文对象
6033
+ * @param url - 猫网基础 URL
6034
+ * @param cardId - 卡号
6035
+ * @param pin - 四位数字 PIN
6036
+ * @returns 验证是否通过
6037
+ */
6038
+ async verifyPin(ctx, url, cardId, pin) {
6039
+ const baseURL = "https://maomani.cn";
6040
+ const resp = await ctx.http.post(
6041
+ "/api/login/account",
6042
+ {
6043
+ username: cardId,
6044
+ password: pin
6045
+ },
6046
+ { baseURL }
6047
+ );
6048
+ return resp?.errorCode === 200;
6049
+ }
6050
+ async uploadScore(ctx, url, cardId, scorePayload) {
6051
+ const baseURL = "https://maomani.cn";
6052
+ const userName = await this.getUserName(ctx, url, cardId);
6053
+ const resp = await ctx.http.post(
6054
+ `/api/logs/upload?name=${encodeURIComponent(`${userName}`)}&pw=logs`,
6055
+ scorePayload,
6056
+ {
6057
+ baseURL
6058
+ }
6059
+ );
6060
+ return resp === "Logs-573sdvx-json-导入成功";
6061
+ }
5854
6062
  async getAllScore(ctx, url, cardId, config) {
5855
6063
  try {
5856
6064
  const data = await ctx.http.get(`/sdvx/scores?card=${cardId}`, {
@@ -6149,11 +6357,19 @@ var SDVXService3 = class _SDVXService {
6149
6357
  const scoresData = response;
6150
6358
  const stringMusicTitles = [...new Set(scoresData.map((score) => score.title))];
6151
6359
  const musicService = MusicService.getInstance(config);
6152
- const musicDataList = await musicService.getMusic(ctx, stringMusicTitles);
6360
+ const [musicDataList, titleMidPairs] = await Promise.all([
6361
+ musicService.getMusic(ctx, stringMusicTitles),
6362
+ musicService.extTitleToMid(ctx, stringMusicTitles)
6363
+ ]);
6364
+ const titleMidMap = new Map(
6365
+ titleMidPairs.filter(({ mid }) => !!mid).map(({ title, mid }) => [title.toLowerCase(), mid])
6366
+ );
6153
6367
  const musicDataMap = new Map(musicDataList.map((music) => [music.title_name, music]));
6154
6368
  const sdvxScores = [];
6155
6369
  for (const scoreData of scoresData) {
6156
6370
  const musicData = musicDataMap.get(scoreData.title);
6371
+ const mappedMid = titleMidMap.get(scoreData.title.toLowerCase());
6372
+ const musicId = mappedMid && /^\d+$/.test(mappedMid) ? Number(mappedMid) : scoreData.music_id;
6157
6373
  for (const [diffKey, diffScore] of Object.entries(scoreData.difficulties)) {
6158
6374
  const diffAbbr = diffKey.toUpperCase();
6159
6375
  const diffInfo = getDiffStringFromAbbr(diffAbbr);
@@ -6167,7 +6383,7 @@ var SDVXService3 = class _SDVXService {
6167
6383
  }
6168
6384
  const scoreObj = {
6169
6385
  music: {
6170
- music_id: scoreData.music_id,
6386
+ music_id: musicId,
6171
6387
  music_diff: difficultyData?.difnum || 0,
6172
6388
  music_diff_name: getDiffName(diffStr, infVer),
6173
6389
  music_diff_full_name: getDiffFullName(diffStr, infVer),
@@ -6317,9 +6533,10 @@ function recent(ctx, config, logger6) {
6317
6533
  const serverService = new ServerService(ctx);
6318
6534
  const userCards = await cardService.getCardsByUid(session.user.id);
6319
6535
  if (userCards.length === 0) return session.text(".card-not-found");
6320
- const userRes = await serverService.getServersByUid(session.user.id);
6321
- const channelRes = atGuild ? await serverService.getServersByCid(session.channel.id) : [];
6322
- const serverRes = channelRes.concat(userRes);
6536
+ const serverRes = await serverService.getSelectableServers(
6537
+ session.user.id,
6538
+ atGuild ? session.channel.id : null
6539
+ );
6323
6540
  if (serverRes.length === 0) return session.text(".server-not-found");
6324
6541
  let cardCode = "";
6325
6542
  if (!options.card) {
@@ -6404,6 +6621,252 @@ function recent(ctx, config, logger6) {
6404
6621
  }
6405
6622
  __name(recent, "recent");
6406
6623
 
6624
+ // src/games/sdvx/commands/sync.ts
6625
+ function sync(ctx, config, logger6) {
6626
+ ctx.command("sdvx.sync").userFields(["id"]).channelFields(["id"]).action(async ({ session }) => {
6627
+ const serverService = new ServerService(ctx);
6628
+ const cardService = new CardService(ctx);
6629
+ const atGuild = session.guildId != null;
6630
+ if (atGuild) return session.text(".dm-only");
6631
+ const allServers = await serverService.getSelectableServers(
6632
+ session.user.id,
6633
+ atGuild ? session.channel.id : null
6634
+ );
6635
+ const maoServer = allServers.find((server2) => server2.type === "mao");
6636
+ if (!maoServer) {
6637
+ return session.text(".mao-not-found");
6638
+ }
6639
+ const sourceServers = allServers;
6640
+ if (sourceServers.length === 0) {
6641
+ return session.text(".source-server-not-found");
6642
+ }
6643
+ let serverListMsg = "";
6644
+ for (let i = 0; i < sourceServers.length; i++) {
6645
+ serverListMsg += `${i + 1}. ${sourceServers[i].name} (${sourceServers[i].type})
6646
+ `;
6647
+ }
6648
+ await session.send(
6649
+ session.text(".source-server-select", { server_list: serverListMsg })
6650
+ );
6651
+ const serverSelect = await session.prompt();
6652
+ if (!serverSelect) return session.text("commands.timeout");
6653
+ if (serverSelect === "q") return session.text(".quit");
6654
+ const serverIndex = Number(serverSelect);
6655
+ if (!Number.isInteger(serverIndex) || serverIndex < 1 || serverIndex > sourceServers.length) {
6656
+ return session.text(".invalid-select");
6657
+ }
6658
+ const sourceServer = sourceServers[serverIndex - 1];
6659
+ const cards = await cardService.getCardsByUid(session.user.id);
6660
+ if (cards.length === 0) return session.text(".card-not-found");
6661
+ const defaultCard = await cardService.getDefaultCardByUid(session.user.id);
6662
+ const defaultCardId = defaultCard ? defaultCard.id : 0;
6663
+ let cardListMsg = "";
6664
+ for (let i = 0; i < cards.length; i++) {
6665
+ if (cards[i].id === defaultCardId) {
6666
+ cardListMsg += `${i + 1}. ${cards[i].name} < 默认卡片
6667
+ `;
6668
+ } else {
6669
+ cardListMsg += `${i + 1}. ${cards[i].name}
6670
+ `;
6671
+ }
6672
+ }
6673
+ await session.send(session.text(".source-card-select", { card_list: cardListMsg }));
6674
+ const sourceCardSelect = await session.prompt();
6675
+ if (!sourceCardSelect) return session.text("commands.timeout");
6676
+ if (sourceCardSelect === "q") return session.text(".quit");
6677
+ const sourceCardIndex = Number(sourceCardSelect);
6678
+ if (!Number.isInteger(sourceCardIndex) || sourceCardIndex < 1 || sourceCardIndex > cards.length) {
6679
+ return session.text(".invalid-select");
6680
+ }
6681
+ const sourceCard = cards[sourceCardIndex - 1];
6682
+ await session.send(session.text(".target-card-select", { card_list: cardListMsg }));
6683
+ const targetCardSelect = await session.prompt();
6684
+ if (!targetCardSelect) return session.text("commands.timeout");
6685
+ if (targetCardSelect === "q") return session.text(".quit");
6686
+ const targetCardIndex = Number(targetCardSelect);
6687
+ if (!Number.isInteger(targetCardIndex) || targetCardIndex < 1 || targetCardIndex > cards.length) {
6688
+ return session.text(".invalid-select");
6689
+ }
6690
+ const targetCard = cards[targetCardIndex - 1];
6691
+ if (sourceServer.type === "mao" && sourceCard.id === targetCard.id) {
6692
+ return session.text(".same-card-error");
6693
+ }
6694
+ const maxPinAttempts = 3;
6695
+ let pinVerified = false;
6696
+ const serverManager = ServerManager.getInstance();
6697
+ const maoSdvxService = serverManager.getGameService("mao", "sdvx");
6698
+ const maoVerifyUrl = "https://maomani.cn";
6699
+ if (!maoSdvxService || typeof maoSdvxService.verifyPin !== "function") {
6700
+ logger6.warn("Mao SDVX service does not support PIN verification");
6701
+ return session.text(".pin-verify-error");
6702
+ }
6703
+ const existingPin = await ctx.database.get("sdvx_pin_verified", {
6704
+ uid: session.user.id,
6705
+ cardId: targetCard.id,
6706
+ sid: maoServer.id
6707
+ });
6708
+ if (existingPin.length > 0) {
6709
+ const cachedPin = existingPin[0].pin;
6710
+ try {
6711
+ const ok = await maoSdvxService.verifyPin(
6712
+ ctx,
6713
+ maoVerifyUrl,
6714
+ targetCard.code,
6715
+ cachedPin
6716
+ );
6717
+ if (ok) {
6718
+ pinVerified = true;
6719
+ }
6720
+ } catch (error) {
6721
+ logger6.warn(error);
6722
+ }
6723
+ }
6724
+ if (!pinVerified) {
6725
+ for (let attempt = 0; attempt < maxPinAttempts; attempt++) {
6726
+ await session.send(session.text(".pin-prompt"));
6727
+ const pinInput = await session.prompt();
6728
+ if (!pinInput) return session.text("commands.timeout");
6729
+ if (pinInput === "q") return session.text(".quit");
6730
+ if (!/^\d{4}$/.test(pinInput)) {
6731
+ await session.send(session.text(".pin-invalid"));
6732
+ continue;
6733
+ }
6734
+ try {
6735
+ const ok = await maoSdvxService.verifyPin(
6736
+ ctx,
6737
+ maoVerifyUrl,
6738
+ targetCard.code,
6739
+ pinInput
6740
+ );
6741
+ if (ok) {
6742
+ pinVerified = true;
6743
+ await ctx.database.create("sdvx_pin_verified", {
6744
+ uid: session.user.id,
6745
+ cardId: targetCard.id,
6746
+ sid: maoServer.id,
6747
+ pin: pinInput
6748
+ });
6749
+ break;
6750
+ }
6751
+ await session.send(
6752
+ session.text(".pin-verify-failed", {
6753
+ message: ""
6754
+ })
6755
+ );
6756
+ } catch (error) {
6757
+ logger6.warn(error);
6758
+ await session.send(session.text(".pin-verify-error"));
6759
+ }
6760
+ }
6761
+ if (!pinVerified) return session.text(".pin-too-many");
6762
+ }
6763
+ const sourceSdvxService = serverManager.getGameService(
6764
+ sourceServer.type,
6765
+ "sdvx"
6766
+ );
6767
+ if (!sourceSdvxService || typeof sourceSdvxService.getAllScore !== "function") {
6768
+ return session.text(".fetch-error");
6769
+ }
6770
+ let scoreList = [];
6771
+ try {
6772
+ scoreList = await sourceSdvxService.getAllScore(
6773
+ ctx,
6774
+ sourceServer.baseUrl,
6775
+ sourceCard.code,
6776
+ config
6777
+ );
6778
+ } catch (error) {
6779
+ logger6.warn(error);
6780
+ return session.text(".fetch-error");
6781
+ }
6782
+ if (!scoreList || scoreList.length === 0) {
6783
+ return session.text(".no-scores");
6784
+ }
6785
+ const syncPayload = buildSyncPayload(scoreList);
6786
+ await session.send(
6787
+ session.text(".confirm-sync", {
6788
+ score_count: syncPayload.length
6789
+ })
6790
+ );
6791
+ const confirm = await session.prompt();
6792
+ if (!confirm) return session.text("commands.timeout");
6793
+ if (confirm.toLowerCase() !== "y") return session.text(".quit");
6794
+ try {
6795
+ const ok = await maoSdvxService.uploadScore(
6796
+ ctx,
6797
+ maoServer.baseUrl,
6798
+ targetCard.code,
6799
+ syncPayload
6800
+ );
6801
+ if (!ok) {
6802
+ logger6.warn("Mao SDVX uploadScore returned falsy result");
6803
+ return session.text(".sync-failed");
6804
+ }
6805
+ } catch (error) {
6806
+ logger6.warn(error);
6807
+ return session.text(".sync-error");
6808
+ }
6809
+ return session.text(".selected-summary", {
6810
+ source_server_name: sourceServer.name,
6811
+ source_server_type: sourceServer.type,
6812
+ source_card_name: sourceCard.name,
6813
+ target_card_name: targetCard.name,
6814
+ score_count: syncPayload.length
6815
+ });
6816
+ });
6817
+ }
6818
+ __name(sync, "sync");
6819
+ function clearTypeToMark(clearType) {
6820
+ switch (clearType) {
6821
+ case "S-PUC":
6822
+ return "spuc";
6823
+ case "PUC":
6824
+ return "puc";
6825
+ case "UC":
6826
+ return "uc";
6827
+ case "MC":
6828
+ return "mc";
6829
+ case "HC":
6830
+ return "hc";
6831
+ case "NC":
6832
+ return "nc";
6833
+ case "PLAYED":
6834
+ return "played";
6835
+ case "NO PLAY":
6836
+ return "noplay";
6837
+ default:
6838
+ return "noplay";
6839
+ }
6840
+ }
6841
+ __name(clearTypeToMark, "clearTypeToMark");
6842
+ function buildSyncPayload(scoreList) {
6843
+ const payloadMap = /* @__PURE__ */ new Map();
6844
+ for (const score of scoreList) {
6845
+ const musicIdRaw = String(score.music.music_id);
6846
+ if (!/^\d+$/.test(musicIdRaw)) continue;
6847
+ const musicId = musicIdRaw;
6848
+ let diffName = score.music.music_diff_name;
6849
+ if (!diffName) continue;
6850
+ const upperDiff = diffName.toUpperCase();
6851
+ if (["GRV", "HVN", "VVD", "XCD"].includes(upperDiff)) {
6852
+ diffName = "INF";
6853
+ } else {
6854
+ diffName = upperDiff;
6855
+ }
6856
+ if (!payloadMap.has(musicId)) {
6857
+ payloadMap.set(musicId, { music_id: musicId, difficulties: {} });
6858
+ }
6859
+ const entry = payloadMap.get(musicId);
6860
+ entry.difficulties[diffName] = {
6861
+ score: score.music.score,
6862
+ mark: clearTypeToMark(score.music.clear_type),
6863
+ grade: score.music.score_grade
6864
+ };
6865
+ }
6866
+ return Array.from(payloadMap.values());
6867
+ }
6868
+ __name(buildSyncPayload, "buildSyncPayload");
6869
+
6407
6870
  // src/games/sdvx/commands/vf.ts
6408
6871
  var fs4 = __toESM(require("fs"), 1);
6409
6872
  var import_koishi16 = require("koishi");
@@ -6545,9 +7008,10 @@ function vf(ctx, config, logger6) {
6545
7008
  const serverService = new ServerService(ctx);
6546
7009
  const userCards = await cardService.getCardsByUid(session.user.id);
6547
7010
  if (userCards.length === 0) return session.text(".card-not-found");
6548
- const userRes = await serverService.getServersByUid(session.user.id);
6549
- const channelRes = atGuild ? await serverService.getServersByCid(session.channel.id) : [];
6550
- const serverRes = channelRes.concat(userRes);
7011
+ const serverRes = await serverService.getSelectableServers(
7012
+ session.user.id,
7013
+ atGuild ? session.channel.id : null
7014
+ );
6551
7015
  if (serverRes.length === 0) return session.text(".server-not-found");
6552
7016
  let cardCode = "";
6553
7017
  if (!options.card) {
@@ -6685,6 +7149,7 @@ function apply12(ctx, config) {
6685
7149
  recent(ctx, config, logger5);
6686
7150
  chart(ctx, config, logger5);
6687
7151
  calculate(ctx, config, logger5);
7152
+ sync(ctx, config, logger5);
6688
7153
  }
6689
7154
  __name(apply12, "apply");
6690
7155
 
@@ -6696,6 +7161,19 @@ __export(database_exports3, {
6696
7161
  });
6697
7162
  var name13 = "database";
6698
7163
  function apply13(ctx) {
7164
+ ctx.model.extend(
7165
+ "sdvx_pin_verified",
7166
+ {
7167
+ id: "unsigned",
7168
+ uid: "unsigned",
7169
+ cardId: "unsigned",
7170
+ sid: "unsigned",
7171
+ pin: "string"
7172
+ },
7173
+ {
7174
+ autoInc: true
7175
+ }
7176
+ );
6699
7177
  }
6700
7178
  __name(apply13, "apply");
6701
7179
 
@@ -6711,10 +7189,10 @@ function apply14(ctx) {
6711
7189
  __name(apply14, "apply");
6712
7190
 
6713
7191
  // src/games/sdvx/locales/en-US.yml
6714
- var en_US_default5 = { _config: { $desc: "SDVX Module Settings", default_model: "<p>Default model value (e.g. `2024110700`)</p>", sdvx_data_url: "<p>The URL of the SDVX data service</p>", sdvx_search_url: "<p>The URL of the SDVX search service</p>", official_support_url: "<p>The URL of the SDVX official support service</p>" }, commands: { vf: { description: "Show Noah help information", messages: { "card-not-found": "<p>You haven't bound a card yet, go bind a card first~</p>", "server-not-found": "<p>No available servers, add one yourself~</p>", "no-scores-found": "<p>No scores found, please check your data.</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing {name} [{difstr}], please wait patiently~</p>", "menu-select": "<p>Please select the card you want to use:</p>\n{card_list}\n<p>q. Exit</p>", "invalid-select": "<p>No such option!</p>", quit: "<p>Exited~</p>" } }, sdvx: { recent: { description: "Show recent scores", messages: { "card-not-found": "<p>You haven't bound a card yet, go bind a card first~</p>", "server-not-found": "<p>No available servers, add one yourself~</p>", "no-scores-found": "<p>No scores found, please check your data.</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>" } }, chart: { description: "Show SDVX chart", messages: { prompt: "<p>Which song's chart would you like to view?</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>", "no-result": "<p>Aww, Noah couldn’t find that song~ try another keyword, okay?</p>" } }, calculate: { description: "Calculate volforce value or score", messages: { "invalid-query": "<p>Invalid query parameters, please check your input~</p>", "no-results": "<p>No results found~</p>", "too-many-results": "<p>Too many results! Found {0} results, please narrow down your query (current limit: 500 results)</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>", error: "<p>An error occurred(っ °Д °;)っ</p>" } } } } };
7192
+ var en_US_default5 = { _config: { $desc: "SDVX Module Settings", default_model: "<p>Default model value (e.g. `2024110700`)</p>", sdvx_data_url: "<p>The URL of the SDVX data service</p>", sdvx_search_url: "<p>The URL of the SDVX search service</p>", official_support_url: "<p>The URL of the SDVX official support service</p>" }, commands: { vf: { description: "Show Noah help information", messages: { "card-not-found": "<p>You haven't bound a card yet, go bind a card first~</p>", "server-not-found": "<p>No available servers, add one yourself~</p>", "no-scores-found": "<p>No scores found, please check your data.</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing {name} [{difstr}], please wait patiently~</p>", "menu-select": "<p>Please select the card you want to use:</p>\n{card_list}\n<p>q. Exit</p>", "invalid-select": "<p>No such option!</p>", quit: "<p>Exited~</p>" } }, sdvx: { recent: { description: "Show recent scores", messages: { "card-not-found": "<p>You haven't bound a card yet, go bind a card first~</p>", "server-not-found": "<p>No available servers, add one yourself~</p>", "no-scores-found": "<p>No scores found, please check your data.</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>" } }, chart: { description: "Show SDVX chart", messages: { prompt: "<p>Which song's chart would you like to view?</p>", error: "<p>An error occurred(っ °Д °;)っ</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>", "no-result": "<p>Aww, Noah couldn’t find that song~ try another keyword, okay?</p>" } }, calculate: { description: "Calculate volforce value or score", messages: { "invalid-query": "<p>Invalid query parameters, please check your input~</p>", "no-results": "<p>No results found~</p>", "too-many-results": "<p>Too many results! Found {0} results, please narrow down your query (current limit: 500 results)</p>", drawing: "<p>Noah is drawing, please wait patiently~</p>", error: "<p>An error occurred(っ °Д °;)っ</p>" } }, sync: { description: "Sync scores to Mao", messages: { "mao-not-found": "<p>Mao server not found. Please add a Mao server first, then try syncing again.</p>", "source-server-not-found": "<p>No available source servers. Please add a server first.</p>", "source-server-select": "<p>Please choose the source server:</p>\n{server_list}\n<p>q. Exit</p>", "card-not-found": "<p>You haven't bound any card yet. Please bind a card first.</p>", "source-card-select": "<p>Please choose the source card:</p>\n{card_list}\n<p>q. Exit</p>", "target-card-select": "<p>Please choose the target card (sync to Mao):</p>\n{card_list}\n<p>q. Exit</p>", "invalid-select": "<p>No such option!</p>", quit: "<p>Sync cancelled.</p>", "same-card-error": "<p>When the source server is Mao, the source card and target card cannot be the same. Please choose again.</p>", "dm-only": "<p>For security, please use this command in a private chat.</p>", "pin-prompt": "<p>Please enter the Mao PIN (4 digits):</p>\n<p>q. Exit</p>", "pin-invalid": "<p>Invalid PIN format. Please enter 4 digits.</p>", "pin-verify-failed": "<p>PIN verification failed: {message}</p>", "pin-verify-error": "<p>PIN verification error. Please try again later.</p>", "pin-too-many": "<p>Too many PIN attempts. Please try again later.</p>", "fetch-error": "<p>Failed to fetch scores from the source server. Please try again later.</p>", "no-scores": "<p>No scores available for sync.</p>", "confirm-sync": "<p>Found {score_count} scores. Start sync?</p>\n<p>Type y to confirm, any other key to cancel.</p>", "sync-error": "<p>Error occurred during sync(っ °Д °;)っ</p>", "sync-failed": "<p>Sync failed(っ °Д °;)っ</p>", "selected-summary": "<p>Sync completedヾ(≧▽≦*)o</p>\n<p>Source server: {source_server_name} ({source_server_type})</p>\n<p>Source card: {source_card_name}</p>\n<p>Target card: {target_card_name}</p>\n<p>Tracks synced: {score_count}</p>" } } } } };
6715
7193
 
6716
7194
  // src/games/sdvx/locales/zh-CN.yml
6717
- var zh_CN_default5 = { _config: { $desc: "SDVX 模块设置", default_model: "<p>默认的 model 值(如 `2024110700`)</p>", sdvx_data_url: "<p>SDVX 数据服务的 URL</p>", sdvx_search_url: "<p>SDVX 搜索服务的 URL</p>", official_support_url: "<p>SDVX 官方支持服务的 URL</p>" }, commands: { vf: { description: "查询 SDVX VOLFORCE", messages: { "card-not-found": "<p>你还没绑卡,去绑个卡再来吧~</p>", "server-not-found": "<p>没有可用的服务器哦,自己添加一个吧~</p>", "no-scores-found": "<p>没有找到你的分数数据哦~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>", "menu-select": "<p>请选择你要使用的卡片:</p>\n{card_list}\n<p>q. 退出</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>" } }, sdvx: { recent: { description: "查询最近分数", messages: { "card-not-found": "<p>你还没绑卡,去绑个卡再来吧~</p>", "server-not-found": "<p>没有可用的服务器哦,自己添加一个吧~</p>", "no-scores-found": "<p>没有找到你的分数数据哦~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>" } }, chart: { description: "查询 SDVX 谱面", messages: { prompt: "<p>要查哪首歌的铺面呢?</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在绘制 {name} [{difstr}],请耐心等待~</p>", "no-result": "<p>Noah 没有找到这首歌,换个关键词试试吧~</p>" } }, calculate: { description: "计算 volforce 值或分数", messages: { "invalid-query": "<p>查询参数无效,请检查你的输入~</p>", "no-results": "<p>没有找到符合条件的结果~</p>", "too-many-results": "<p>结果太多啦!共找到 {0} 条结果,请缩小查询范围(当前限制:500 条)</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>" } } } } };
7195
+ var zh_CN_default5 = { _config: { $desc: "SDVX 模块设置", default_model: "<p>默认的 model 值(如 `2024110700`)</p>", sdvx_data_url: "<p>SDVX 数据服务的 URL</p>", sdvx_search_url: "<p>SDVX 搜索服务的 URL</p>", official_support_url: "<p>SDVX 官方支持服务的 URL</p>" }, commands: { vf: { description: "查询 SDVX VOLFORCE", messages: { "card-not-found": "<p>你还没绑卡,去绑个卡再来吧~</p>", "server-not-found": "<p>没有可用的服务器哦,自己添加一个吧~</p>", "no-scores-found": "<p>没有找到你的分数数据哦~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>", "menu-select": "<p>请选择你要使用的卡片:</p>\n{card_list}\n<p>q. 退出</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出~</p>" } }, sdvx: { recent: { description: "查询最近分数", messages: { "card-not-found": "<p>你还没绑卡,去绑个卡再来吧~</p>", "server-not-found": "<p>没有可用的服务器哦,自己添加一个吧~</p>", "no-scores-found": "<p>没有找到你的分数数据哦~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>" } }, chart: { description: "查询 SDVX 谱面", messages: { prompt: "<p>要查哪首歌的铺面呢?</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>", drawing: "<p>Noah 正在绘制 {name} [{difstr}],请耐心等待~</p>", "no-result": "<p>Noah 没有找到这首歌,换个关键词试试吧~</p>" } }, calculate: { description: "计算 volforce 值或分数", messages: { "invalid-query": "<p>查询参数无效,请检查你的输入~</p>", "no-results": "<p>没有找到符合条件的结果~</p>", "too-many-results": "<p>结果太多啦!共找到 {0} 条结果,请缩小查询范围(当前限制:500 条)</p>", drawing: "<p>Noah 正在画啦,请耐心等待~</p>", error: "<p>Noah 遇到了错误(っ °Д °;)っ</p>" } }, sync: { description: "同步成绩到猫网", messages: { "mao-not-found": "<p>没有找到猫网服务器,请先添加猫网服务器后再尝试同步。</p>", "source-server-not-found": "<p>没有可作为来源的服务器,请先添加服务器。</p>", "source-server-select": "<p>请选择来源服务器:</p>\n{server_list}\n<p>q. 退出</p>", "card-not-found": "<p>你还没有绑定任何卡片哦~</p>", "source-card-select": "<p>请选择来源卡片:</p>\n{card_list}\n<p>q. 退出</p>", "target-card-select": "<p>请选择目标卡片(同步到猫网):</p>\n{card_list}\n<p>q. 退出</p>", "invalid-select": "<p>没有该选项!</p>", quit: "<p>已退出同步。</p>", "same-card-error": "<p>来源服务器为猫网时,来源卡片和目标卡片不能相同,请重新选择。</p>", "dm-only": "<p>出于安全考虑,请在私聊中使用该指令。</p>", "pin-prompt": "<p>请输入猫网 PIN 码(4 位数字):</p>\n<p>q. 退出</p>", "pin-invalid": "<p>PIN 码格式不正确,请输入 4 位数字。</p>", "pin-verify-failed": "<p>PIN 验证失败:{message}</p>", "pin-verify-error": "<p>PIN 验证时出现错误,请稍后重试。</p>", "pin-too-many": "<p>PIN 验证次数过多,请稍后再试。</p>", "fetch-error": "<p>获取来源服务器成绩失败,请稍后再试。</p>", "no-scores": "<p>没有可同步的成绩。</p>", "confirm-sync": "<p>共找到 {score_count} 条成绩,是否开始同步?</p>\n<p>输入 y 确认,其他任意键取消。</p>", "sync-error": "<p>同步过程中出现了错误(っ °Д °;)っ</p>", "sync-failed": "<p>同步失败(っ °Д °;)っ</p>", "selected-summary": "<p>同步完成ヾ(≧▽≦*)o</p>\n<p>来源服务器:{source_server_name} ({source_server_type})</p>\n<p>来源卡片:{source_card_name}</p>\n<p>目标卡片:{target_card_name}</p>\n<p>同步曲目数量:{score_count}</p>" } } } } };
6718
7196
 
6719
7197
  // src/games/sdvx/index.ts
6720
7198
  var name15 = "Noah-SDVX";
@@ -9,7 +9,7 @@ export declare class SDVXService implements ISDVXService {
9
9
  static getInstance(logger: Logger): SDVXService;
10
10
  /**
11
11
  * Get the difficulty string based on the difficulty type number
12
- * @param diffType Difficulty type (0-4)
12
+ * @param diffType Difficulty type
13
13
  * @returns The difficulty string (novice, advanced, exhaust, infinite, maximum)
14
14
  */
15
15
  private getDifficultyString;
@@ -21,6 +21,23 @@ export declare class SDVXService implements ISDVXService {
21
21
  * @returns The player ID (e.g., "ED*")
22
22
  */
23
23
  getUserName(ctx: Context, url: string, cardId: string): Promise<string>;
24
+ /**
25
+ * 验证猫网 PIN 码
26
+ * @param ctx - Koishi 上下文对象
27
+ * @param url - 猫网基础 URL
28
+ * @param cardId - 卡号
29
+ * @param pin - 四位数字 PIN
30
+ * @returns 验证是否通过
31
+ */
32
+ verifyPin(ctx: Context, url: string, cardId: string, pin: string): Promise<boolean>;
33
+ uploadScore(ctx: Context, url: string, cardId: string, scorePayload: {
34
+ music_id: string;
35
+ difficulties: Record<string, {
36
+ score: number;
37
+ mark: string;
38
+ grade: string;
39
+ }>;
40
+ }[]): Promise<boolean>;
24
41
  getAllScore(ctx: Context, url: string, cardId: string, config: SDVXConfig): Promise<SDVXScore[]>;
25
42
  getScore(ctx: Context, url: string, cardId: string, musicId: number, config: SDVXConfig): Promise<SDVXScore>;
26
43
  getRecentScores(ctx: Context, url: string, cardId: string, config: SDVXConfig, count?: number): Promise<SDVXScore[]>;
@@ -62,6 +62,31 @@ export interface SDVXService extends GameService {
62
62
  * @returns 玩家 ID
63
63
  */
64
64
  getUserName(ctx: Context, url: string, cardId: string): Promise<string>;
65
+ /**
66
+ * 校验 PIN 码
67
+ * @param ctx - Koishi 上下文对象
68
+ * @param url - API 基础 URL
69
+ * @param cardId - 玩家卡片 ID
70
+ * @param pin - 四位数字 PIN
71
+ * @returns 是否验证通过
72
+ */
73
+ verifyPin?(ctx: Context, url: string, cardId: string, pin: string): Promise<boolean>;
74
+ /**
75
+ * 上传分数数据
76
+ * @param ctx - Koishi 上下文对象
77
+ * @param url - API 基础 URL
78
+ * @param cardId - 玩家卡片 ID
79
+ * @param scorePayload - 待上传的分数数据
80
+ * @returns 上传是否成功
81
+ */
82
+ uploadScore?(ctx: Context, url: string, cardId: string, scorePayload: {
83
+ music_id: string;
84
+ difficulties: Record<string, {
85
+ score: number;
86
+ mark: string;
87
+ grade: string;
88
+ }>;
89
+ }[]): Promise<boolean>;
65
90
  }
66
91
  /**
67
92
  * 服务器接口
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-noah",
3
- "version": "1.8.2",
3
+ "version": "2.0.1",
4
4
  "contributors": [
5
5
  "Logthm <logthm@outlook.com>"
6
6
  ],
@@ -51,7 +51,7 @@
51
51
  "@ltxhhz/koishi-plugin-skia-canvas": "^0.0.8",
52
52
  "@types/adm-zip": "^0",
53
53
  "@types/xml2js": "^0",
54
- "eslint": "^9.39.1",
54
+ "eslint": "^9.39.2",
55
55
  "eslint-config-prettier": "^10.1.8",
56
56
  "eslint-import-resolver-typescript": "^3.10.1",
57
57
  "eslint-plugin-prettier": "^5.5.4",