koishi-plugin-chatluna-wows 1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ ## 1.0.1
2
+
3
+ - 详细玩家战绩新增前十高频船只的累计场次、最近战斗时间、胜率、伤害、击沉和 PR。
4
+
5
+ # 更新日志
6
+
7
+ ## 1.0.0
8
+
9
+ - 首个公开正式版本,作者 Nanami Leung。
10
+ - 使用 `窝窝屎查询` 入口,空参数显示帮助;支持账号绑定、总战绩、详细数据、单船、舰船资料、社区 PR 基准查询。
11
+ - 720 像素手机竖版长图;默认、战舰少女 R、碧蓝航线三种风格,均支持深浅主题。立绘按皮肤、改造、普通顺序选择。
12
+ - 包含空配置启动修复、可保存的空 Application ID、缺少 ID 时的查询提示、社区 PR 正确 JSON 地址、立绘下载回退与 60 秒失败缓存。
13
+ - 移除原始异常日志输出,避免 API 请求信息进入日志;自定义 PR 镜像完整地址不再显示在图片或返回元数据中。
14
+ - 明确发布文件白名单、空凭据默认值和数据处理说明。替换失效的 TypeScript / Vitest 脚本,提供可运行的 JavaScript 构建检查与真实 Koishi 测试。
15
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nanami Leung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PRIVACY.md ADDED
@@ -0,0 +1,20 @@
1
+ # 数据与隐私
2
+
3
+ 本插件不内置作者或其他用户的 Application ID、机器人令牌、账号绑定、邮箱、Cookie 或私有服务地址。每个部署者在 Koishi 插件配置页面填写自己的 Wargaming Application ID;该字段以密文输入框展示,实际存储保护由 Koishi 和部署环境负责。
4
+
5
+ ## 使用的数据
6
+
7
+ - 查询向所选地区的 Wargaming 官方 API 发送 Application ID、玩家昵称 / 账号 ID、舰船 ID 及查询模式,读取公开战绩。插件不使用 Wargaming 账号密码或私有资料访问令牌。
8
+ - 绑定记录存储在管理员配置的 Koishi 数据库 `wows_binding` 表中,包含平台、用户 ID、服务器、游戏账号 ID、昵称、创建及更新时间。用户可通过 `窝窝屎查询 解绑 全部` 删除自己的绑定。
9
+ - PNG 会显示所查询玩家的公开昵称、游戏账号 ID、公开战绩等。绑定用户可被其他用户通过 @ 查询;平台用户 ID 不写入 PNG。
10
+ - 社区 PR 请求默认访问 WoWS-Numbers,也可由管理员设置镜像;不会向此图源发送 Wargaming Application ID。自定义镜像的完整地址仅用于请求,PNG 和返回的 PR 元数据用通用来源标签代替,以避免暴露地址中的认证信息。
11
+ - 立绘启用时访问舰 R 百科或碧蓝航线 BWiki 及其图片域名,只发送角色页面查询,不发送 Wargaming Application ID 或 Koishi 用户 ID。查询和图片临时缓存在插件进程内,重启后清空。
12
+ - 插件自身不添加遥测、统计上报或向作者发送数据的功能。可选 ChatLuna 功能受管理员已有模型与平台配置约束。
13
+
14
+ ## 日志与发布包
15
+
16
+ 插件日志记录错误类别、HTTP 状态码和立绘失败阶段,不直接打印原始 HTTP 错误对象、请求地址、响应正文或本地堆栈。Koishi、网络代理、其他插件及第三方站点可能拥有独立日志策略。
17
+
18
+ 发布采用文件白名单,不包含本机 Koishi 配置、环境变量文件、npm 登录配置、数据库、缓存、预览图、测试下载页面或历史安装包。源码包含的测试数据仅使用显式生成的虚构值。
19
+
20
+ 作者署名 Nanami Leung 为公开发布信息,不附带邮箱或私人路径。
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # koishi-plugin-chatluna-wows
2
+
3
+ 战舰世界公开战绩查询插件,作者 **Nanami Leung**。输入 `窝窝屎查询` 查看帮助,支持玩家战绩、单船战绩、舰船资料与社区 PR 基准,生成便于手机阅读的竖版 PNG。可选接入 ChatLuna。
4
+
5
+ ## 安装与启用
6
+
7
+ - Node.js 18 或更高版本,Koishi 4.18.9 或更高版本。
8
+ - 启用任一 Koishi 数据库插件和 `@koishijs/plugin-puppeteer` 3.2.x。
9
+ - 安装本插件后,在插件页面填写自己的 Wargaming Application ID。
10
+ - ChatLuna 为可选服务,当前支持 `koishi-plugin-chatluna` **1.4.0-rc.0**;未安装时中文指令照常使用,不声明兼容其他 ChatLuna 版本。
11
+
12
+ 发布到 npm 并被市场收录后,可在 Koishi 插件市场搜索 `chatluna-wows` 安装。使用 Yarn 管理的 Koishi 项目也可以执行:
13
+
14
+ ```bash
15
+ corepack yarn add koishi-plugin-chatluna-wows @koishijs/plugin-puppeteer
16
+ ```
17
+
18
+ 本地安装包放入 Koishi 项目的 `packages` 目录后:
19
+
20
+ ```bash
21
+ corepack yarn add "file:./packages/koishi-plugin-chatluna-wows-1.0.0.tgz"
22
+ ```
23
+
24
+ 保持与主项目相同的包管理器,不要在 Yarn 项目中混用 `npm install`。遇到 `isn't present in your lockfile` 时,在 Koishi 主项目根目录运行 `corepack yarn install` 同步锁文件,再启动 Koishi,不需要删除锁文件。
25
+
26
+ Application ID 默认为空,不内置任何人的凭据。空配置允许保存、加载插件和查看帮助;实际官方 API 查询前必须配置有效 ID。API 申请入口:[Wargaming Developer Room](https://developers.wargaming.net/)。
27
+
28
+ ## 指令
29
+
30
+ ```text
31
+ 窝窝屎查询
32
+ 窝窝屎查询 帮助
33
+ 窝窝屎查询 我的 [服务器]
34
+ 窝窝屎查询 <昵称或账号ID> [服务器]
35
+ 窝窝屎查询 @群友 [服务器]
36
+ 窝窝屎查询 绑定 <昵称或账号ID> [服务器]
37
+ 窝窝屎查询 解绑 [服务器]
38
+ 窝窝屎查询 解绑 全部
39
+ 窝窝屎查询 绑定列表
40
+ 窝窝屎查询 玩家 <昵称或账号ID> [服务器]
41
+ 窝窝屎查询 详细 [昵称或账号ID|@群友] [模式] [服务器]
42
+ 窝窝屎查询 单船 <舰名或舰船ID> [详细] [模式] [服务器]
43
+ 窝窝屎查询 单船 <昵称或账号ID> <舰名或舰船ID> [详细] [模式] [服务器]
44
+ 窝窝屎查询 单船 @群友 <舰名或舰船ID> [详细] [模式] [服务器]
45
+ 窝窝屎查询 舰船信息 <舰名或舰船ID> [服务器]
46
+ 窝窝屎查询 PR基准 <舰名或舰船ID> [服务器]
47
+ ```
48
+
49
+ 服务器支持亚服 / 欧服 / 美服和 `asia / eu / na`。帮助别名为 `帮助`、`help`、`/help`;舰船资料别名为 `舰船`、`资料`;PR 基准别名为 `基准`、`期望值`、`PR`。旧 `战绩` 指令不再注册。
50
+
51
+ 支持随机、单排、双排、三排、合作、排位、单排排位、双排排位、三排排位、军团、行动单人、行动组队、困难行动。单船仅支持随机及其组队拆分、合作、排位;官方逐舰接口不提供军团或行动的单船数据。
52
+
53
+ 每位用户每个服务器可绑定一个账号,按平台和用户 ID 跨群生效。绑定昵称只接受大小写不敏感的完全匹配,未匹配时列出候选。其他用户可通过 @ 查询绑定账号的公开数据。带空格的舰名会先按完整舰名匹配;不明确时显示候选舰船及 ID。
54
+
55
+ ## PNG 风格与立绘
56
+
57
+ 卡片宽度固定 720 像素,最低高度 1280 像素;详细数据自动生成长图,并在详细总战绩图片底部加入按累计场次排序的前十艘船只及场次。支持简体中文、繁體中文、English,以及深色 / 浅色主题。入口指令始终为中文。
58
+
59
+ 在插件页面「卡片外观 → PNG 个性化风格」选择:
60
+
61
+ | 风格 | 配置值 | 总战绩背景 | 单船 / 资料 / PR 基准背景 |
62
+ | --- | --- | --- | --- |
63
+ | 默认 | `default` | 普通背景 | 舰 R 同名角色 |
64
+ | 战舰少女 R | `warship-girls-r` | 随机舰 R 角色 | 舰 R 同名角色 |
65
+ | 碧蓝航线 | `azur-lane` | 随机碧蓝角色 | 碧蓝同名角色 |
66
+
67
+ 立绘位于数据下方并渐变淡出,按 **皮肤 → 改造 → 普通** 优先选择。舰 R 使用正常立绘,不使用大破图;多个皮肤选一个。无对应角色时使用普通背景,单船不会借用其他角色。风格以各游戏的视觉语言重新设计,不是游戏客户端 UI 截图。
68
+
69
+ | 设置 | 默认值 | 说明 |
70
+ | --- | --- | --- |
71
+ | `shipArtworkEnabled` | `true` | 所有风格的角色立绘总开关 |
72
+ | `shipArtworkOpacity` | `0.52` | 可见度 0–1,步进 0.01;0 完全透明 |
73
+ | `artworkTimeoutSeconds` | `10` | 单次页面或图片请求超时,最多可设 15 秒 |
74
+ | `shipArtworkMappings` | `{}` | 舰船 ID 或官方舰名 → 舰 R 百科页面名 |
75
+ | `azurArtworkMappings` | `{}` | 舰船 ID 或官方舰名 → 碧蓝 BWiki 页面名 |
76
+ | `randomArtworkNames` | `[]` | 随机角色页面名列表;空列表使用内置池 |
77
+
78
+ 映射以舰船 ID 优先,空值禁用该舰立绘,例如 `Hood` → `胡德`;值填角色页面名,不填图片网址。内置随机池为胡德、俾斯麦、列克星敦、萨拉托加、企业、黎塞留,不保证每个角色或皮肤一直可访问。
79
+
80
+ 舰 R 图片下载失败会尝试同皮肤缩略图,再回退同舰改造或普通立绘。同角色请求总时间最多为超时设置的三倍;总战绩最多尝试两个角色,默认最坏约 60 秒,不含排队。已有配置的超时值保留。成功缓存 24 小时,失败缓存 60 秒,最多 48 条、约 24 MiB 编码图片。随机角色均失败时可复用池内有效缓存。
81
+
82
+ 图源为[舰 R 百科](https://www.zjsnrwiki.com/)和[碧蓝航线 BWiki](https://wiki.biligame.com/blhx/)。插件先下载图片后嵌入 PNG;截图页面不访问外网。字体使用服务器本地字体,不下载网络字体。
83
+
84
+ ## 舰船资料与社区 PR
85
+
86
+ 舰船资料来自 Wargaming `wows/encyclopedia/ships`,展示默认模块配置的介绍、等级、国家、类型、生命值、航速、火炮、鱼雷、隐蔽、转向、装甲及价格。未提供的数据显示 `—`,不包含舰长技能、升级品和信号旗加成。
87
+
88
+ PR 基准来自 WoWS-Numbers,显示期望场均伤害、期望场均击沉、期望胜率和获取时间。三项均达到期望值时公式结果为 **PR 1150**,这不是全体玩家平均 PR。数据源不提供全体玩家实时平均 PR 或样本人数。
89
+
90
+ ```text
91
+ nDamage = max(0, (actualDamage / expectedDamage - 0.4) / 0.6)
92
+ nFrags = max(0, (actualFrags / expectedFrags - 0.1) / 0.9)
93
+ nWins = max(0, (actualWins / expectedWins - 0.7) / 0.3)
94
+ PR = 700 * nDamage + 300 * nFrags + 150 * nWins
95
+ ```
96
+
97
+ 玩家 PR 仅在随机总览和随机单船卡显示,其他模式显示不适用;期望数据覆盖率默认低于 90% 时不显示数值。PR 是社区指标,不是 Wargaming 官方评分。
98
+
99
+ 默认期望值接口为 `https://api.wows-numbers.com/personal/rating/expected/json/`,旧版默认地址自动迁移。可通过 `expectedValuesUrl` 配置兼容镜像;自定义完整地址不写入卡片或返回元数据。缓存 24 小时,并发请求合并;失败后等待 5 分钟重试,可使用进程内最后一份有效数据并标注缓存状态。首次失败只影响 PR,不影响官方战绩。
100
+
101
+ ## ChatLuna
102
+
103
+ 检测到兼容的 ChatLuna 后注册 `wows_statistics` 工具,可查询并向当前会话发送 PNG,不能绑定或解绑账号。`queryType` 支持 `statistics`、`ship-info`、`ship-pr`;后两者必须提供 `ship`,无需绑定游戏账号。同会话重复工具调用默认 30 秒内只发一次图片。
104
+
105
+ ## 常见问题
106
+
107
+ - **配置保存失败或指令不存在**:确认安装的是 1.0.0,数据库与 Puppeteer 均已启用。新版在创建服务前补齐空配置默认值;空 ID 可查看帮助,查询前需填写。Koishi 全局指令前缀、过滤器及权限设置仍会影响触发。
108
+ - **舰 R 风格没有立绘**:检查风格、立绘开关及非零可见度;超时建议 10–15 秒。升级后重新启用插件以清除旧失败缓存。可将随机池暂设为 `胡德` 复查。
109
+ - **`artwork unavailable`**:日志 `stage=page` 为百科请求失败,`stage=parse` 为未匹配图片,`stage=image` 为下载失败;`reason` 显示 HTTP 状态或超时。仍需确保部署服务器可访问图源,插件无法保证第三方解除 403。
110
+ - **图片文字显示方框**:在部署环境安装中文字体,并在 `fontFamily` 中填写实际可用的字体名称。
111
+ - **社区 PR 暂不可用**:检查上述 JSON 接口是否可访问,必要时配置镜像。HTML 验证页、空数组、零值及非法期望数据不会参与 PR 计算。
112
+
113
+ 默认缓存 / 限流:战绩 5 分钟、昵称搜索 30 分钟、查询冷却 10 秒、请求超时 10 秒、失败重试一次、并发查询 4 个,可在插件页面调整。
114
+
115
+ ## 开发与发布
116
+
117
+ 本包以 `lib/*.js` 为可维护 JavaScript 源码,并附带 TypeScript 声明;不需要未提供的 TypeScript 工程。`npm run build` 检查语法、发布元数据与敏感信息模式,`npm test` 使用真实 Koishi Schema 和生命周期测试,以及模拟 HTTP 测试;测试不读取真实凭据。请在独立源码目录开发,不在运行中的 Koishi 主项目切换包管理器。
118
+
119
+ ```bash
120
+ npm install
121
+ npm run build
122
+ npm test
123
+ npm pack
124
+ ```
125
+
126
+ 发布命令(需自己的 npm 发布权限;会公开上传包):
127
+
128
+ ```bash
129
+ npm publish ./koishi-plugin-chatluna-wows-1.0.0.tgz --access public --registry https://registry.npmjs.org/
130
+ ```
131
+
132
+ 插件市场根据 npm 元数据收录,发布成功后通常需要等待索引更新;生成本地安装包不等于已经上架。参见[Koishi 发布指南](https://koishi.chat/zh-CN/guide/develop/publish.html)。没有公开仓库,因此未填写 repository / homepage。
133
+
134
+ ## 隐私与许可
135
+
136
+ 详见 [PRIVACY.md](./PRIVACY.md)。插件不内置私人凭据,不附带本机配置、数据库、日志、下载页面或预览图片;API ID 由每个部署者自行配置。绑定信息保存在部署者的 Koishi 数据库中。
137
+
138
+ 代码为 MIT 许可,Copyright © 2026 Nanami Leung。World of Warships、Wargaming、WoWS-Numbers、战舰少女 R、碧蓝航线及其数据和素材归各自权利人所有。第三方立绘和期望值不随安装包分发,不适用本代码的 MIT 许可。请遵守 [Wargaming API Rules](https://developers.wargaming.net/documentation/rules/) 及相应图源的使用条款。
139
+
package/lib/api.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Context } from 'koishi';
2
+ import type { AccountInfo, AccountSearchResult, BattleMode, ExpectedShipValues, ExpectedValuesSnapshot, PluginConfig, Realm, ShipInfo, ShipStats } from './types';
3
+ export declare class WowsApi {
4
+ private readonly ctx;
5
+ private readonly config;
6
+ private readonly searchCache;
7
+ private readonly accountCache;
8
+ private readonly shipsCache;
9
+ private readonly shipCatalogCache;
10
+ private readonly shipInfoCache;
11
+ private readonly semaphore;
12
+ private expected?;
13
+ private expectedPending?;
14
+ private expectedRetryAt;
15
+ private expectedFailure?;
16
+ private refreshExpectedValues;
17
+ constructor(ctx: Context, config: PluginConfig);
18
+ private requestEnvelope;
19
+ private request;
20
+ searchAccounts(realm: Realm, query: string): Promise<AccountSearchResult[]>;
21
+ resolveAccount(realm: Realm, target: string, exactNickname?: boolean): Promise<{
22
+ account: AccountSearchResult | null;
23
+ candidates: AccountSearchResult[];
24
+ }>;
25
+ getAccount(realm: Realm, accountId: string | number, mode: BattleMode): Promise<AccountInfo>;
26
+ getShips(realm: Realm, accountId: string | number, mode?: BattleMode, shipId?: number): Promise<ShipStats[]>;
27
+ getShipCatalog(realm: Realm): Promise<ShipInfo[]>;
28
+ getShipInfo(realm: Realm, shipId: number): Promise<ShipInfo>;
29
+ resolveShip(realm: Realm, query: string, exactOnly?: boolean): Promise<{
30
+ ship: ShipInfo | null;
31
+ candidates: ShipInfo[];
32
+ }>;
33
+ getExpectedValues(): Promise<ExpectedValuesSnapshot>;
34
+ }
35
+ export declare function normalizeShipName(value: string): string;
36
+ export declare function parseExpectedValues(payload: unknown): Map<number, ExpectedShipValues>;
37
+ export declare const DEFAULT_EXPECTED_VALUES_URL = "https://api.wows-numbers.com/personal/rating/expected/json/";
38
+ export declare function resolveExpectedValuesUrl(value?: string): string;
package/lib/api.js ADDED
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WowsApi = void 0;
4
+ exports.normalizeShipName = normalizeShipName;
5
+ exports.parseExpectedValues = parseExpectedValues;
6
+ exports.resolveExpectedValuesUrl = resolveExpectedValuesUrl;
7
+ const DEFAULT_EXPECTED_VALUES_URL = 'https://api.wows-numbers.com/personal/rating/expected/json/';
8
+ exports.DEFAULT_EXPECTED_VALUES_URL = DEFAULT_EXPECTED_VALUES_URL;
9
+ const EXPECTED_RETRY_MS = 5 * 60_000;
10
+ function resolveExpectedValuesUrl(value) {
11
+ const url = typeof value === 'string' ? value.trim() : '';
12
+ if (!url || /^https:\/\/wows-numbers\.com\/personal\/rating\/expected\.json\/?$/.test(url))
13
+ return DEFAULT_EXPECTED_VALUES_URL;
14
+ return url;
15
+ }
16
+ const cache_1 = require("./cache");
17
+ const types_1 = require("./types");
18
+ const { safeError, publicExpectedSource } = require('./privacy');
19
+ const apiHosts = {
20
+ asia: 'https://api.worldofwarships.asia',
21
+ eu: 'https://api.worldofwarships.eu',
22
+ na: 'https://api.worldofwarships.com',
23
+ };
24
+ class WowsApi {
25
+ ctx;
26
+ config;
27
+ searchCache;
28
+ accountCache;
29
+ shipsCache;
30
+ shipCatalogCache;
31
+ shipInfoCache;
32
+ semaphore;
33
+ expected;
34
+ expectedPending;
35
+ expectedRetryAt = 0;
36
+ expectedFailure;
37
+ constructor(ctx, config) {
38
+ this.ctx = ctx;
39
+ this.config = config;
40
+ this.searchCache = new cache_1.TtlCache(config.searchCacheMinutes * 60_000);
41
+ this.accountCache = new cache_1.TtlCache(config.statsCacheMinutes * 60_000);
42
+ this.shipsCache = new cache_1.TtlCache(config.statsCacheMinutes * 60_000);
43
+ this.shipCatalogCache = new cache_1.TtlCache(config.searchCacheMinutes * 60_000);
44
+ this.shipInfoCache = new cache_1.TtlCache(config.searchCacheMinutes * 60_000);
45
+ this.semaphore = new cache_1.Semaphore(config.maxConcurrency);
46
+ }
47
+ async requestEnvelope(realm, path, params) {
48
+ if (!this.config.applicationId?.trim()) throw new types_1.WowsError('missing_application_id');
49
+ return this.semaphore.run(async () => {
50
+ let lastError;
51
+ for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) {
52
+ try {
53
+ const language = this.config.locale === 'en-US' ? 'en' : this.config.locale.toLowerCase();
54
+ const response = await this.ctx.http.get(`${apiHosts[realm]}${path}`, {
55
+ params: { application_id: this.config.applicationId, language, ...params },
56
+ timeout: this.config.requestTimeoutSeconds * 1000,
57
+ });
58
+ if (response.status !== 'ok' || response.data === undefined)
59
+ throw new Error(response.error?.message ?? 'unknown API response');
60
+ return response;
61
+ }
62
+ catch (error) {
63
+ lastError = error;
64
+ if (attempt < this.config.retryCount)
65
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
66
+ }
67
+ }
68
+ throw new types_1.WowsError('api_error', safeError(lastError));
69
+ });
70
+ }
71
+ async request(realm, path, params) {
72
+ return (await this.requestEnvelope(realm, path, params)).data;
73
+ }
74
+ async searchAccounts(realm, query) {
75
+ const key = `${realm}:${query.toLowerCase()}`;
76
+ const cached = this.searchCache.get(key);
77
+ if (cached)
78
+ return cached;
79
+ const data = await this.request(realm, '/wows/account/list/', { search: query, limit: 20, type: 'startswith' });
80
+ this.searchCache.set(key, data);
81
+ return data;
82
+ }
83
+ async resolveAccount(realm, target, exactNickname = false) {
84
+ if (/^\d+$/.test(target)) {
85
+ const account = await this.getAccount(realm, target, 'pvp');
86
+ return { account: { account_id: account.account_id, nickname: account.nickname }, candidates: [] };
87
+ }
88
+ const candidates = await this.searchAccounts(realm, target);
89
+ const account = candidates.find((item) => item.nickname.localeCompare(target, undefined, { sensitivity: 'accent' }) === 0) ?? null;
90
+ if (account || exactNickname)
91
+ return { account, candidates: candidates.slice(0, 5) };
92
+ return { account: candidates.length === 1 ? candidates[0] : null, candidates: candidates.slice(0, 5) };
93
+ }
94
+ async getAccount(realm, accountId, mode) {
95
+ const key = `${realm}:${accountId}:${mode}`;
96
+ const cached = this.accountCache.get(key);
97
+ if (cached)
98
+ return cached;
99
+ const extra = mode === 'pvp' ? '' : mode;
100
+ const data = await this.request(realm, '/wows/account/info/', {
101
+ account_id: accountId,
102
+ ...(extra ? { extra: `statistics.${extra}` } : {}),
103
+ });
104
+ const account = data[String(accountId)];
105
+ if (!account)
106
+ throw new types_1.WowsError('not_found');
107
+ if (account.hidden_profile)
108
+ throw new types_1.WowsError('hidden_profile');
109
+ this.accountCache.set(key, account);
110
+ return account;
111
+ }
112
+ async getShips(realm, accountId, mode = 'pvp', shipId) {
113
+ const key = `${realm}:${accountId}:${mode}:${shipId ?? 'all'}`;
114
+ const cached = this.shipsCache.get(key);
115
+ if (cached)
116
+ return cached;
117
+ const data = await this.request(realm, '/wows/ships/stats/', {
118
+ account_id: accountId,
119
+ ...(shipId ? { ship_id: shipId } : {}),
120
+ ...(mode === 'pvp' ? {} : { extra: mode }),
121
+ });
122
+ const ships = data[String(accountId)] ?? [];
123
+ this.shipsCache.set(key, ships);
124
+ return ships;
125
+ }
126
+ async getShipCatalog(realm) {
127
+ const cached = this.shipCatalogCache.get(realm);
128
+ if (cached)
129
+ return cached;
130
+ const ships = [];
131
+ for (let page = 1; page <= 20; page += 1) {
132
+ const response = await this.requestEnvelope(realm, '/wows/encyclopedia/ships/', {
133
+ fields: 'ship_id,name,tier,type,nation,is_premium,is_special', limit: 100, page_no: page,
134
+ });
135
+ ships.push(...Object.values(response.data ?? {}).filter((ship) => Boolean(ship?.ship_id && ship.name)));
136
+ const pageTotal = Number(response.meta?.page_total ?? page);
137
+ if (page >= pageTotal)
138
+ break;
139
+ }
140
+ this.shipCatalogCache.set(realm, ships);
141
+ return ships;
142
+ }
143
+ async resolveShip(realm, query, exactOnly = false) {
144
+ const catalog = await this.getShipCatalog(realm);
145
+ const numericId = /^\d+$/.test(query.trim()) ? Number(query.trim()) : null;
146
+ if (numericId !== null) {
147
+ const ship = catalog.find((item) => item.ship_id === numericId) ?? null;
148
+ return { ship, candidates: ship ? [] : [] };
149
+ }
150
+ const normalized = normalizeShipName(query);
151
+ const exact = catalog.filter((item) => normalizeShipName(item.name) === normalized);
152
+ if (exact.length === 1)
153
+ return { ship: exact[0], candidates: [] };
154
+ if (exact.length > 1 || exactOnly)
155
+ return { ship: null, candidates: exact.slice(0, 8) };
156
+ const candidates = catalog
157
+ .filter((item) => normalizeShipName(item.name).includes(normalized))
158
+ .sort((left, right) => Number(normalizeShipName(right.name).startsWith(normalized)) - Number(normalizeShipName(left.name).startsWith(normalized)) || right.tier - left.tier || left.name.localeCompare(right.name))
159
+ .slice(0, 8);
160
+ return { ship: candidates.length === 1 ? candidates[0] : null, candidates };
161
+ }
162
+ async getShipInfo(realm, shipId) {
163
+ const key = `${realm}:${shipId}`;
164
+ const cached = this.shipInfoCache.get(key);
165
+ if (cached) return cached;
166
+ const data = await this.request(realm, '/wows/encyclopedia/ships/', { ship_id: shipId });
167
+ const ship = data[String(shipId)];
168
+ if (!ship) throw new types_1.WowsError('ship_not_found');
169
+ this.shipInfoCache.set(key, ship);
170
+ return ship;
171
+ }
172
+ async getExpectedValues() {
173
+ const ttl = (this.config.expectedValuesTtlHours ?? 24) * 3_600_000;
174
+ if (this.expected && Date.now() - this.expected.fetchedAt < ttl)
175
+ return this.expected;
176
+ if (this.expectedPending)
177
+ return this.expectedPending;
178
+ if (Date.now() < this.expectedRetryAt) {
179
+ if (this.expected)
180
+ return { ...this.expected, stale: true };
181
+ throw this.expectedFailure;
182
+ }
183
+ const pending = this.refreshExpectedValues();
184
+ this.expectedPending = pending;
185
+ try { return await pending; }
186
+ finally { this.expectedPending = undefined; }
187
+ }
188
+ async refreshExpectedValues() {
189
+ const source = resolveExpectedValuesUrl(this.config.expectedValuesUrl);
190
+ try {
191
+ const payload = await this.semaphore.run(() => this.ctx.http.get(source, {
192
+ timeout: (this.config.requestTimeoutSeconds ?? 10) * 1000,
193
+ responseType: 'json',
194
+ headers: { Accept: 'application/json' },
195
+ maxContentLength: 10_000_000,
196
+ }));
197
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
198
+ throw new Error('expected values response is not a JSON object');
199
+ if (JSON.stringify(payload).length > 10_000_000)
200
+ throw new Error('expected values response exceeds 10 MB');
201
+ const values = parseExpectedValues(payload);
202
+ if (!values.size)
203
+ throw new Error('expected values response is empty');
204
+ this.expected = { values, fetchedAt: Date.now(), source: publicExpectedSource(source), stale: false };
205
+ this.expectedRetryAt = 0;
206
+ this.expectedFailure = undefined;
207
+ return this.expected;
208
+ }
209
+ catch (error) {
210
+ this.expectedRetryAt = Date.now() + EXPECTED_RETRY_MS;
211
+ const status = error?.response?.status ?? error?.status;
212
+ const reason = Number.isInteger(status) ? `HTTP ${status}` : error?.message === 'Forbidden' ? 'HTTP 403 Forbidden' : 'request failed or invalid JSON data';
213
+ this.expectedFailure = new types_1.WowsError('api_error', `expected values unavailable: ${reason}; 请检查「社区 PR」期望值地址或配置可访问的镜像。5 分钟后重试。`);
214
+ if (this.expected)
215
+ return { ...this.expected, stale: true };
216
+ throw this.expectedFailure;
217
+ }
218
+ }
219
+ }
220
+ exports.WowsApi = WowsApi;
221
+ function normalizeShipName(value) {
222
+ return value.normalize('NFKC').toLocaleLowerCase().replace(/[\s._'’·-]+/g, '');
223
+ }
224
+ function parseExpectedValues(payload) {
225
+ if (!payload || typeof payload !== 'object')
226
+ return new Map();
227
+ const root = payload;
228
+ const data = (root.data && typeof root.data === 'object' ? root.data : root);
229
+ const result = new Map();
230
+ for (const [rawId, rawValue] of Object.entries(data)) {
231
+ const shipId = Number(rawId);
232
+ if (!Number.isInteger(shipId) || shipId <= 0 || !rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue))
233
+ continue;
234
+ const item = rawValue;
235
+ const value = {
236
+ average_damage_dealt: Number(item.average_damage_dealt),
237
+ average_frags: Number(item.average_frags),
238
+ win_rate: Number(item.win_rate),
239
+ };
240
+ if (Object.values(value).every((number) => Number.isFinite(number) && number > 0) && value.win_rate <= 100)
241
+ result.set(shipId, value);
242
+ }
243
+ return result;
244
+ }
@@ -0,0 +1,122 @@
1
+ {
2
+ "胡德": ["Hood"],
3
+ "俾斯麦": ["Bismarck", "俾斯麥"],
4
+ "提尔比茨": ["Tirpitz", "提爾比茨"],
5
+ "黎塞留": ["Richelieu"],
6
+ "让·巴尔": ["Jean Bart", "让巴尔", "讓巴爾", "讓·巴爾"],
7
+ "列克星敦": ["Lexington"],
8
+ "萨拉托加": ["Saratoga", "薩拉托加"],
9
+ "企业": ["Enterprise", "企業"],
10
+ "约克城": ["Yorktown", "約克城"],
11
+ "大黄蜂": ["Hornet", "大黃蜂"],
12
+ "埃塞克斯": ["Essex"],
13
+ "突击者": ["Ranger", "突擊者"],
14
+ "兰利": ["Langley", "蘭利"],
15
+ "竞技神": ["Hermes", "競技神"],
16
+ "皇家方舟": ["Ark Royal"],
17
+ "光荣": ["Glorious", "光榮"],
18
+ "声望": ["Renown", "聲望"],
19
+ "反击": ["Repulse", "反擊"],
20
+ "纳尔逊": ["Nelson", "納爾遜"],
21
+ "罗德尼": ["Rodney", "羅德尼"],
22
+ "威尔士亲王": ["Prince of Wales", "威爾士親王"],
23
+ "英王乔治五世": ["King George V", "英王喬治五世"],
24
+ "前卫": ["Vanguard", "前衛"],
25
+ "厌战": ["Warspite", "厭戰"],
26
+ "伊丽莎白女王": ["Queen Elizabeth", "伊麗莎白女王"],
27
+ "安德烈亚·多利亚": ["Andrea Doria", "安德烈亞·多利亞"],
28
+ "罗马": ["Roma", "羅馬"],
29
+ "维内托": ["Vittorio Veneto", "维托里奥·维内托", "維托里奧·維內托"],
30
+ "利托里奥": ["Littorio", "利托里奧"],
31
+ "长门": ["Nagato", "長門"],
32
+ "陆奥": ["Mutsu", "陸奧"],
33
+ "扶桑": ["Fuso", "Fusō"],
34
+ "山城": ["Yamashiro"],
35
+ "伊势": ["Ise", "伊勢"],
36
+ "日向": ["Hyuga", "Hyūga"],
37
+ "金刚": ["Kongo", "Kongō", "金剛"],
38
+ "比睿": ["Hiei"],
39
+ "榛名": ["Haruna"],
40
+ "雾岛": ["Kirishima", "霧島"],
41
+ "赤城": ["Akagi"],
42
+ "加贺": ["Kaga", "加賀"],
43
+ "翔鹤": ["Shokaku", "Shōkaku", "翔鶴"],
44
+ "瑞鹤": ["Zuikaku", "瑞鶴"],
45
+ "龙骧": ["Ryujo", "Ryūjō", "龍驤"],
46
+ "凤翔": ["Hosho", "Hōshō", "鳳翔"],
47
+ "最上": ["Mogami"],
48
+ "高雄": ["Takao"],
49
+ "爱宕": ["Atago", "愛宕"],
50
+ "青叶": ["Aoba", "青葉"],
51
+ "妙高": ["Myoko", "Myōkō"],
52
+ "北上": ["Kitakami"],
53
+ "岛风": ["Shimakaze", "島風"],
54
+ "吹雪": ["Fubuki"],
55
+ "绫波": ["Ayanami", "綾波"],
56
+ "晓": ["Akatsuki", "曉"],
57
+ "响": ["Hibiki", "響"],
58
+ "雪风": ["Yukikaze", "雪風"],
59
+ "时雨": ["Shigure", "時雨"],
60
+ "夕立": ["Yudachi", "Yūdachi"],
61
+ "得梅因": ["Des Moines", "德梅因", "德梅茵"],
62
+ "塞勒姆": ["Salem"],
63
+ "巴尔的摩": ["Baltimore", "巴爾的摩"],
64
+ "威奇塔": ["Wichita"],
65
+ "新奥尔良": ["New Orleans", "新奧爾良"],
66
+ "彭萨科拉": ["Pensacola", "彭薩科拉"],
67
+ "印第安纳波利斯": ["Indianapolis", "印第安納波利斯"],
68
+ "波特兰": ["Portland", "波特蘭"],
69
+ "北安普顿": ["Northampton", "北安普頓"],
70
+ "克利夫兰": ["Cleveland", "克里夫兰", "克里夫蘭"],
71
+ "亚特兰大": ["Atlanta", "亞特蘭大"],
72
+ "朱诺": ["Juneau", "朱諾"],
73
+ "海伦娜": ["Helena", "海倫娜"],
74
+ "布鲁克林": ["Brooklyn", "布魯克林"],
75
+ "奥马哈": ["Omaha", "奧馬哈"],
76
+ "弗莱彻": ["Fletcher", "弗萊徹"],
77
+ "基林": ["Gearing"],
78
+ "西格斯比": ["Sigsbee"],
79
+ "艾奥瓦": ["Iowa", "衣阿华", "愛荷華"],
80
+ "密苏里": ["Missouri", "密蘇里"],
81
+ "新泽西": ["New Jersey", "新澤西"],
82
+ "威斯康星": ["Wisconsin", "威斯康辛"],
83
+ "北卡罗来纳": ["North Carolina", "北卡羅來納"],
84
+ "华盛顿": ["Washington", "華盛頓"],
85
+ "南达科他": ["South Dakota", "南達科他"],
86
+ "马萨诸塞": ["Massachusetts", "麻薩諸塞"],
87
+ "阿拉巴马": ["Alabama", "阿拉巴馬"],
88
+ "科罗拉多": ["Colorado", "科羅拉多"],
89
+ "西弗吉尼亚": ["West Virginia", "西維吉尼亞"],
90
+ "马里兰": ["Maryland", "馬里蘭"],
91
+ "田纳西": ["Tennessee", "田納西"],
92
+ "加利福尼亚": ["California", "加利福尼亞"],
93
+ "亚利桑那": ["Arizona", "亞利桑那"],
94
+ "宾夕法尼亚": ["Pennsylvania", "賓夕法尼亞"],
95
+ "内华达": ["Nevada", "內華達"],
96
+ "俄克拉荷马": ["Oklahoma", "奧克拉荷馬"],
97
+ "欧根亲王": ["Prinz Eugen", "歐根親王"],
98
+ "希佩尔海军上将": ["Admiral Hipper", "希佩尔", "希佩爾海軍上將"],
99
+ "舍尔海军上将": ["Admiral Scheer", "舍爾海軍上將"],
100
+ "斯佩伯爵海军上将": ["Admiral Graf Spee", "斯佩伯爵", "施佩伯爵海軍上將"],
101
+ "沙恩霍斯特": ["Scharnhorst"],
102
+ "格奈森瑙": ["Gneisenau"],
103
+ "齐柏林伯爵": ["Graf Zeppelin", "齊柏林伯爵"],
104
+ "柯尼斯堡": ["Konigsberg", "Königsberg", "柯尼斯堡"],
105
+ "纽伦堡": ["Nurnberg", "Nürnberg", "紐倫堡"],
106
+ "莱比锡": ["Leipzig", "萊比錫"],
107
+ "纽卡斯尔": ["Newcastle", "紐卡斯爾"],
108
+ "贝尔法斯特": ["Belfast", "貝爾法斯特"],
109
+ "爱丁堡": ["Edinburgh", "愛丁堡"],
110
+ "谢菲尔德": ["Sheffield", "謝菲爾德"],
111
+ "天狼星": ["Sirius"],
112
+ "翡翠": ["Emerald"],
113
+ "萤火虫": ["Glowworm", "螢火蟲"],
114
+ "哥萨克人": ["Cossack", "哥薩克人"],
115
+ "爱斯基摩人": ["Eskimo", "愛斯基摩人"],
116
+ "空想": ["Le Fantasque"],
117
+ "凯旋": ["Le Triomphant", "凱旋"],
118
+ "苏联": ["Sovetsky Soyuz", "蘇聯"],
119
+ "基洛夫": ["Kirov"],
120
+ "摩尔曼斯克": ["Murmansk", "摩爾曼斯克"],
121
+ "重庆": ["Chungking", "重慶"]
122
+ }
@@ -0,0 +1,9 @@
1
+ import type { Context } from 'koishi';
2
+ import type { PluginConfig, ShipInfo, ShipArtworkData } from './types';
3
+ export declare function artworkTitle(ship: ShipInfo, config: PluginConfig, provider?: 'warship-girls-r' | 'azur-lane'): string;
4
+ export declare function extractArtwork(html: string): { url: string; name: string } | null;
5
+ export declare function imageDataUri(raw: ArrayBuffer | Buffer): string;
6
+ export declare class ShipArtwork {
7
+ constructor(ctx: Context, config: PluginConfig);
8
+ resolve(ship?: ShipInfo): Promise<ShipArtworkData | null>;
9
+ }