koishi-plugin-ets2-tools-tmp 3.4.1 → 3.4.3

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.
@@ -46,11 +46,13 @@ module.exports = {
46
46
  },
47
47
  /**
48
48
  * 查询在线玩家
49
+ * @param game 可选,2=美卡(接口不传默认欧卡)
49
50
  */
50
- async mapPlayerList(http, serverId, ax, ay, bx, by) {
51
+ async mapPlayerList(http, serverId, ax, ay, bx, by, game) {
51
52
  let result = null
52
53
  try {
53
- result = await requestWithFallback(http, `/map/playerList?aAxisX=${ax}&aAxisY=${ay}&bAxisX=${bx}&bAxisY=${by}&serverId=${serverId}`)
54
+ const gameParam = game ? `&game=${game}` : ''
55
+ result = await requestWithFallback(http, `/map/playerList?aAxisX=${ax}&aAxisY=${ay}&bAxisX=${bx}&bAxisY=${by}&serverId=${serverId}${gameParam}`)
54
56
  } catch {
55
57
  return {
56
58
  error: true
@@ -1,26 +1,20 @@
1
1
  const queryPointV1 = require("./queryPointV1");
2
+ const tmpIdPicker = require("../../../util/tmpIdPicker");
2
3
 
3
4
  module.exports = async (ctx, cfg, session, targetQQ) => {
4
5
  let queryQQ = targetQQ;
5
6
  if (!queryQQ) {
6
7
  queryQQ = session.userId;
7
8
  } else {
8
- if (queryQQ.startsWith("<at ")) {
9
- if (queryQQ.startsWith('<at ')) {
10
- queryQQ = queryQQ.replace('<at ', '');
9
+ if (String(queryQQ).includes("<at ")) {
10
+ const ats = tmpIdPicker.parseAt(queryQQ);
11
+ const atUser = (Array.isArray(ats) && ats.length > 0) ? ats[0] : null;
12
+ if (!atUser || !atUser.id) {
13
+ return "无法识别@的用户,请输入纯数字QQ号";
11
14
  }
12
- let id = '';
13
- const idStart = queryQQ.indexOf('id="');
14
- if (idStart !== -1) {
15
- const valueStart = idStart + 4;
16
- const valueEnd = queryQQ.indexOf('"', valueStart);
17
- if (valueEnd !== -1) {
18
- id = queryQQ.substring(valueStart, valueEnd);
19
- }
20
- }
21
- queryQQ = id;
15
+ queryQQ = atUser.id;
22
16
  }
23
- if (!/^\d+$/.test(queryQQ)) {
17
+ if (!/^\d+$/.test(String(queryQQ))) {
24
18
  return "QQ号格式不正确,请输入纯数字QQ号";
25
19
  }
26
20
  }
@@ -16,8 +16,13 @@ module.exports = async (ctx, cfg, session, tmpId) => {
16
16
  // 追加绑定(同一 tmpId 已绑定过则提示,不重复入库)
17
17
  const result = await guildBind.add(ctx.database, session.platform, session.userId, playerInfo.data.tmpId, playerInfo.data.name);
18
18
  if (result.exists) {
19
- return `该编号已在绑定列表中 ( ${playerInfo.data.name} ),无需重复绑定`;
19
+ const hint = result.isDefault ? '(该编号已是你的默认绑定)' : '';
20
+ return `该编号已在绑定列表中 ( ${playerInfo.data.name} )${hint},无需重复绑定`;
20
21
  }
21
22
  const count = (await guildBind.list(ctx.database, session.platform, session.userId)).length;
22
- return `绑定成功 ( ${playerInfo.data.name} ),当前已绑定 ${count} 个编号`;
23
+ if (count === 1) {
24
+ return `绑定成功 ( ${playerInfo.data.name} ),当前已绑定 ${count} 个编号(已自动设为默认绑定)`;
25
+ }
26
+ const defaultTip = result.isDefault ? '(已设为默认绑定)' : '';
27
+ return `绑定成功 ( ${playerInfo.data.name} )${defaultTip},当前已绑定 ${count} 个编号`;
23
28
  };
@@ -57,38 +57,55 @@ module.exports = async (ctx, session, serverType, tmpId, date) => {
57
57
  return '未启用 puppeteer 服务'
58
58
  }
59
59
 
60
- if (tmpId && tmpId.startsWith("<at ")) {
61
- if (tmpId.startsWith('<at ')) {
62
- queryQQ = tmpId.replace('<at ', '');
63
- }
64
- let id = '';
65
- const idStart = queryQQ.indexOf('id="');
66
- if (idStart !== -1) {
67
- const valueStart = idStart + 4;
68
- const valueEnd = queryQQ.indexOf('"', valueStart);
69
- if (valueEnd !== -1) {
70
- id = queryQQ.substring(valueStart, valueEnd);
60
+ if (tmpId && typeof tmpId === 'string' && tmpId.includes('<at ')) {
61
+ const ats = tmpIdPicker.parseAt(tmpId);
62
+ let atUser = null;
63
+ if (Array.isArray(ats) && ats.length > 0) atUser = ats[0];
64
+ else if (ats && ats.failed) atUser = { id: null, name: '' };
65
+ if (atUser) {
66
+ if (!atUser.id) {
67
+ return `无法识别@的用户`;
71
68
  }
69
+ if (atUser.name) tmpIdPicker.cacheAtName(session.platform, atUser.id, atUser.name);
70
+ const atBindings = await guildBind.listOf(ctx.database, session.platform, atUser.id);
71
+ if (atBindings.length === 0) {
72
+ return `该用户没有绑定玩家编号`;
73
+ }
74
+ // 多个绑定时挂起提问(@别人 也走多选);足迹不出图量大,禁用 all
75
+ const picked = await tmpIdPicker.pick(ctx, session, atBindings, {
76
+ ownerKey: atUser.id,
77
+ ownerName: atUser.name || '',
78
+ allowAll: false
79
+ });
80
+ if (picked === null) {
81
+ return // 已列出序号等待用户回复
82
+ }
83
+ if (picked.length > 1) {
84
+ const results = []
85
+ for (const id of picked) {
86
+ const r = await module.exports(ctx, session, serverType, id, date)
87
+ if (r != null) results.push(r)
88
+ }
89
+ return results.length === 1 ? results[0] : results
90
+ }
91
+ tmpId = picked[0];
72
92
  }
73
- queryQQ = id;
74
- let guildBindData = await guildBind.get(ctx.database, session.platform, queryQQ);
75
- if (!guildBindData) {
76
- return `该用户没有绑定玩家编号`;
77
- }
78
- tmpId = guildBindData.tmp_id;
79
93
  }
80
94
 
95
+ // 注意:足迹出图量大(Puppeteer 渲染 + 大量历史点位),刻意**不支持 all**,
96
+ // 避免一次请求打满服务器;两处 pick 都传 allowAll:false 关掉挂起期的 all。
81
97
  if (tmpId && isNaN(tmpId)) {
82
98
  return `请输入正确的玩家编号,或绑定玩家编号`
83
99
  }
84
100
 
85
101
  // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选=发多张足迹图)
102
+ // 足迹不出图量大,禁用 all(避免一次请求打满服务器)
86
103
  if (!tmpId) {
87
104
  const bindings = await guildBind.list(ctx.database, session.platform, session.userId)
88
105
  if (bindings.length === 0) {
89
106
  return `请输入正确的玩家编号,或绑定玩家编号`
90
107
  }
91
- const picked = await tmpIdPicker.pick(ctx, session, bindings)
108
+ const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowAll: false })
92
109
  if (picked === null) {
93
110
  return // 已列出序号等待用户回复
94
111
  }
@@ -4,7 +4,7 @@ const common = require('../util/common');
4
4
  const evmOpenApi = require('../api/evmOpenApi');
5
5
  const guildBind = require('../database/guildBind');
6
6
  const tmpIdPicker = require('../util/tmpIdPicker');
7
- module.exports = async (ctx, session, rankingType) => {
7
+ module.exports = async (ctx, session, rankingType, target) => {
8
8
  if (!ctx.puppeteer) {
9
9
  return '未启用 Puppeteer 功能';
10
10
  }
@@ -16,17 +16,25 @@ module.exports = async (ctx, session, rankingType) => {
16
16
  else if (mileageRankingList.data.length === 0) {
17
17
  return '暂无数据';
18
18
  }
19
- // 查询当前玩家的排行信息(多绑定时先单选一个,排行榜只高亮一个人)
20
- const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
21
- let bindTmpId = null;
22
- if (bindings.length === 1) {
23
- bindTmpId = String(bindings[0].tmp_id);
24
- } else if (bindings.length > 1) {
25
- const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowMulti: false });
26
- if (picked === null) {
27
- return; // 已列出序号等待用户回复
19
+ // 支持 @别人 查询:拿对方的绑定来高亮(排行榜只高亮一个人)
20
+ let ownerKey = session.userId;
21
+ let ownerName = '';
22
+ if (target && typeof target === 'string' && target.includes('<at ')) {
23
+ const ats = tmpIdPicker.parseAt(target);
24
+ const atUser = (Array.isArray(ats) && ats.length > 0) ? ats[0] : null;
25
+ if (!atUser || !atUser.id) {
26
+ return `无法识别@的用户`;
28
27
  }
29
- bindTmpId = picked[0];
28
+ ownerKey = atUser.id;
29
+ ownerName = atUser.name || '';
30
+ if (ownerName) tmpIdPicker.cacheAtName(session.platform, ownerKey, ownerName);
31
+ }
32
+ // 排行榜只高亮一个人:直接用默认绑定,不弹序号询问
33
+ const bindings = await guildBind.listOf(ctx.database, session.platform, ownerKey);
34
+ let bindTmpId = null;
35
+ if (bindings.length > 0) {
36
+ await guildBind.ensureNames(ctx, bindings);
37
+ bindTmpId = String((bindings.find(b => guildBind.isDefault(b)) || bindings[0]).tmp_id);
30
38
  }
31
39
  let playerMileageRanking = null;
32
40
  if (bindTmpId) {
@@ -6,6 +6,7 @@ const truckyAppApi = require('../api/truckyAppApi')
6
6
  const truckersMpApi = require('../api/truckersMpApi')
7
7
  const evmOpenApi = require('../api/evmOpenApi')
8
8
  const baiduTranslate = require('../util/baiduTranslate')
9
+ const { AtsIds } = require('../util/constant')
9
10
  const common = require('../util/common')
10
11
 
11
12
  /**
@@ -13,25 +14,40 @@ const common = require('../util/common')
13
14
  */
14
15
  module.exports = async (ctx, cfg, session, tmpId) => {
15
16
  if (ctx.puppeteer) {
16
- if (tmpId && tmpId.startsWith("<at ")) {
17
- if (tmpId.startsWith('<at ')) {
18
- queryQQ = tmpId.replace('<at ', '');
19
- }
20
- let id = '';
21
- const idStart = queryQQ.indexOf('id="');
22
- if (idStart !== -1) {
23
- const valueStart = idStart + 4;
24
- const valueEnd = queryQQ.indexOf('"', valueStart);
25
- if (valueEnd !== -1) {
26
- id = queryQQ.substring(valueStart, valueEnd);
27
- }
28
- }
29
- queryQQ = id;
30
- let guildBindData = await guildBind.get(ctx.database, session.platform, queryQQ);
31
- if (!guildBindData) {
32
- return `该用户没有绑定玩家编号`;
17
+ let atUser = null;
18
+ if (tmpId && typeof tmpId === 'string' && tmpId.includes('<at ')) {
19
+ const ats = tmpIdPicker.parseAt(tmpId);
20
+ if (Array.isArray(ats) && ats.length > 0) atUser = ats[0];
21
+ else if (ats && ats.failed) atUser = { id: null, name: '' };
22
+ if (atUser && atUser.name) tmpIdPicker.cacheAtName(session.platform, atUser.id, atUser.name);
23
+ }
24
+ if (atUser) {
25
+ const queryQQ = atUser.id;
26
+ if (!queryQQ) {
27
+ return `无法识别@的用户`;
28
+ }
29
+ const atBindings = await guildBind.listOf(ctx.database, session.platform, queryQQ);
30
+ if (atBindings.length === 0) {
31
+ return `该用户没有绑定玩家编号`;
32
+ }
33
+ // 多个绑定时挂起提问(是谁 @ 的不影响回复的人;被 @ 的人回复即可);定位不支持 all
34
+ const picked = await tmpIdPicker.pick(ctx, session, atBindings, {
35
+ ownerKey: queryQQ,
36
+ ownerName: atUser.name || '',
37
+ allowAll: false
38
+ });
39
+ if (picked === null) {
40
+ return; // 已列出序号等待用户回复
41
+ }
42
+ if (picked.length > 1) {
43
+ const results = [];
44
+ for (const id of picked) {
45
+ const r = await module.exports(ctx, cfg, session, id);
46
+ if (r != null) results.push(r);
33
47
  }
34
- tmpId = guildBindData.tmp_id;
48
+ return results.length === 1 ? results[0] : results;
49
+ }
50
+ tmpId = picked[0];
35
51
  }
36
52
  // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选=发多张定位图)
37
53
  if (!tmpId) {
@@ -39,7 +55,7 @@ module.exports = async (ctx, cfg, session, tmpId) => {
39
55
  if (bindings.length === 0) {
40
56
  return `请输入正确的玩家编号`
41
57
  }
42
- const picked = await tmpIdPicker.pick(ctx, session, bindings)
58
+ const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowAll: false })
43
59
  if (picked === null) {
44
60
  return // 已列出序号等待用户回复
45
61
  }
@@ -53,6 +69,11 @@ module.exports = async (ctx, cfg, session, tmpId) => {
53
69
  }
54
70
  tmpId = picked[0]
55
71
  }
72
+ // 注意:定位刻意**不支持 all**(每个绑定都要各查一次玩家信息+线上位置,会放大请求量),
73
+ // 两处 pick 都传 allowAll:false 关掉挂起期的 all。
74
+ if (tmpId && isNaN(tmpId)) {
75
+ return `请输入正确的玩家编号,或绑定玩家编号`
76
+ }
56
77
 
57
78
  // 查询玩家信息
58
79
  let playerInfo = await truckersMpApi.player(ctx.http, tmpId)
@@ -69,12 +90,15 @@ module.exports = async (ctx, cfg, session, tmpId) => {
69
90
  return '玩家离线'
70
91
  }
71
92
 
72
- // 查询周边玩家,并处理数据
93
+ // 查询周边玩家,并处理数据(美卡需显式传 game=2,接口不传默认欧卡)
94
+ const atsServerIdList = AtsIds
95
+ const isAts = atsServerIdList.indexOf(playerMapInfo.data.server) !== -1
73
96
  let areaPlayersData = await evmOpenApi.mapPlayerList(ctx.http, playerMapInfo.data.server,
74
97
  playerMapInfo.data.x - 4000,
75
98
  playerMapInfo.data.y + 2500,
76
99
  playerMapInfo.data.x + 4000,
77
- playerMapInfo.data.y - 2500)
100
+ playerMapInfo.data.y - 2500,
101
+ isAts ? 2 : undefined)
78
102
  let areaPlayerList = []
79
103
  if (!areaPlayersData.error) {
80
104
  areaPlayerList = areaPlayersData.data
@@ -94,9 +118,9 @@ module.exports = async (ctx, cfg, session, tmpId) => {
94
118
  // promods服ID集合
95
119
  let promodsServerIdList = [50, 51]
96
120
 
97
- // 构建地图数据
121
+ // 构建地图数据(在线的美卡服务器 → 用美卡地图)
98
122
  let data = {
99
- mapType: promodsServerIdList.indexOf(playerMapInfo.data.server) !== -1 ? 'promods' : 'ets',
123
+ mapType: isAts ? 'ats' : (promodsServerIdList.indexOf(playerMapInfo.data.server) !== -1 ? 'promods' : 'ets'),
100
124
  avatar: playerInfo.data.smallAvatar,
101
125
  username: playerInfo.data.name,
102
126
  serverName: playerMapInfo.data.serverDetails.name,
@@ -3,6 +3,7 @@ const dayjsRelativeTime = require('dayjs/plugin/relativeTime');
3
3
  const dayjsLocaleZhCn = require('dayjs/locale/zh-cn');
4
4
  const guildBind = require('../../database/guildBind');
5
5
  const tmpIdPicker = require('../../util/tmpIdPicker');
6
+ const { AtsIds } = require('../../util/constant');
6
7
  const truckyAppApi = require('../../api/truckyAppApi');
7
8
  const evmOpenApi = require('../../api/evmOpenApi');
8
9
  const baiduTranslate = require('../../util/baiduTranslate');
@@ -63,23 +64,38 @@ module.exports = async (ctx, cfg, session, tmpId) => {
63
64
  if (!ctx.puppeteer) {
64
65
  return '未启用 puppeteer 服务';
65
66
  }
66
- if (tmpId && tmpId.startsWith("<at ")) {
67
- let queryQQ = tmpId.replace('<at ', '');
68
- let id = '';
69
- const idStart = queryQQ.indexOf('id="');
70
- if (idStart !== -1) {
71
- const valueStart = idStart + 4;
72
- const valueEnd = queryQQ.indexOf('"', valueStart);
73
- if (valueEnd !== -1) {
74
- id = queryQQ.substring(valueStart, valueEnd);
75
- }
67
+ let atUser = null;
68
+ if (tmpId && typeof tmpId === 'string' && tmpId.includes('<at ')) {
69
+ const ats = tmpIdPicker.parseAt(tmpId);
70
+ if (Array.isArray(ats) && ats.length > 0) atUser = ats[0];
71
+ else if (ats && ats.failed) atUser = { id: null, name: '' };
72
+ if (atUser && atUser.name) tmpIdPicker.cacheAtName(session.platform, atUser.id, atUser.name);
73
+ }
74
+ if (atUser) {
75
+ if (!atUser.id) {
76
+ return `无法识别@的用户`;
76
77
  }
77
- queryQQ = id;
78
- let guildBindData = await guildBind.get(ctx.database, session.platform, queryQQ);
79
- if (!guildBindData) {
78
+ const atBindings = await guildBind.listOf(ctx.database, session.platform, atUser.id);
79
+ if (atBindings.length === 0) {
80
80
  return `该用户没有绑定玩家编号`;
81
81
  }
82
- tmpId = guildBindData.tmp_id;
82
+ // 多个绑定时挂起提问(@别人 也走多选)
83
+ const picked = await tmpIdPicker.pick(ctx, session, atBindings, {
84
+ ownerKey: atUser.id,
85
+ ownerName: atUser.name || ''
86
+ });
87
+ if (picked === null) {
88
+ return; // 已列出序号等待用户回复
89
+ }
90
+ if (picked.length > 1) {
91
+ const results = [];
92
+ for (const id of picked) {
93
+ const r = await module.exports(ctx, cfg, session, id);
94
+ if (r != null) results.push(r);
95
+ }
96
+ return results.length === 1 ? results[0] : results;
97
+ }
98
+ tmpId = picked[0];
83
99
  }
84
100
  // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选)
85
101
  if (!tmpId) {
@@ -101,6 +117,23 @@ module.exports = async (ctx, cfg, session, tmpId) => {
101
117
  }
102
118
  tmpId = picked[0];
103
119
  }
120
+ // 「all / 全部」写法:一次性查询全部绑定(受多选上限截断)
121
+ if (tmpIdPicker.isAllArg(tmpId)) {
122
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
123
+ const { ids, hint } = await tmpIdPicker.pickAll(ctx, session, bindings);
124
+ if (ids.length === 0) {
125
+ return `请输入正确的玩家编号`;
126
+ }
127
+ const results = [];
128
+ for (const id of ids) {
129
+ const r = await module.exports(ctx, cfg, session, id);
130
+ if (r != null) results.push(r);
131
+ }
132
+ if (hint) {
133
+ await session.send(hint).catch(() => { });
134
+ }
135
+ return results.length === 1 ? results[0] : results;
136
+ }
104
137
  // 并行请求玩家信息与线上状态(互不依赖,避免串行等待)
105
138
  const [playerInfo, playerMapInfo] = await Promise.all([
106
139
  evmOpenApi.playerInfo(ctx.http, tmpId),
@@ -211,7 +244,10 @@ module.exports = async (ctx, cfg, session, tmpId) => {
211
244
  data.onlineCity = onlineCity;
212
245
  data.onlineX = playerMapInfo.data.x;
213
246
  data.onlineY = playerMapInfo.data.y;
214
- data.onlineMapType = playerMapInfo.data.serverDetails.id === 50 ? 'promods' : 'ets';
247
+ // 美卡服务器(8/10/45)用美卡地图,Promods 用 Promods 地图,其余用欧卡地图
248
+ data.onlineMapType = playerMapInfo.data.serverDetails.id === 50
249
+ ? 'promods'
250
+ : (AtsIds.indexOf(playerMapInfo.data.serverDetails.id) !== -1 ? 'ats' : 'ets');
215
251
  } else if (playerInfo.data.lastOnlineTime) {
216
252
  data.lastOnlineTime = dayjs(playerInfo.data.lastOnlineTime).fromNow(false);
217
253
  }
@@ -50,25 +50,38 @@ const userGroup = {
50
50
  */
51
51
  module.exports = async (ctx, cfg, session, tmpId) => {
52
52
  const { vtcId } = cfg.tmpActivityService?.api || {};
53
- if (tmpId && tmpId.startsWith("<at ")) {
54
- if (tmpId.startsWith('<at ')) {
55
- queryQQ = tmpId.replace('<at ', '');
56
- }
57
- let id = '';
58
- const idStart = queryQQ.indexOf('id="');
59
- if (idStart !== -1) {
60
- const valueStart = idStart + 4;
61
- const valueEnd = queryQQ.indexOf('"', valueStart);
62
- if (valueEnd !== -1) {
63
- id = queryQQ.substring(valueStart, valueEnd);
64
- }
53
+ let atUser = null;
54
+ if (tmpId && typeof tmpId === 'string' && tmpId.includes('<at ')) {
55
+ const ats = tmpIdPicker.parseAt(tmpId);
56
+ if (Array.isArray(ats) && ats.length > 0) atUser = ats[0];
57
+ else if (ats && ats.failed) atUser = { id: null, name: '' };
58
+ if (atUser && atUser.name) tmpIdPicker.cacheAtName(session.platform, atUser.id, atUser.name);
59
+ }
60
+ if (atUser) {
61
+ if (!atUser.id) {
62
+ return `无法识别@的用户`;
65
63
  }
66
- queryQQ = id;
67
- let guildBindData = await guildBind.get(ctx.database, session.platform, queryQQ);
68
- if (!guildBindData) {
64
+ const atBindings = await guildBind.listOf(ctx.database, session.platform, atUser.id);
65
+ if (atBindings.length === 0) {
69
66
  return `该用户没有绑定玩家编号`;
70
67
  }
71
- tmpId = guildBindData.tmp_id;
68
+ // 多个绑定时挂起提问(@别人 也走多选)
69
+ const picked = await tmpIdPicker.pick(ctx, session, atBindings, {
70
+ ownerKey: atUser.id,
71
+ ownerName: atUser.name || ''
72
+ });
73
+ if (picked === null) {
74
+ return; // 已列出序号等待用户回复
75
+ }
76
+ if (picked.length > 1) {
77
+ const results = [];
78
+ for (const id of picked) {
79
+ const r = await module.exports(ctx, cfg, session, id);
80
+ if (r != null) results.push(r);
81
+ }
82
+ return results.length === 1 ? results[0] : results;
83
+ }
84
+ tmpId = picked[0];
72
85
  }
73
86
  // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选)
74
87
  if (!tmpId) {
@@ -90,6 +103,23 @@ module.exports = async (ctx, cfg, session, tmpId) => {
90
103
  }
91
104
  tmpId = picked[0];
92
105
  }
106
+ // 「all / 全部」写法:一次性查询全部绑定(受多选上限截断)
107
+ if (tmpIdPicker.isAllArg(tmpId)) {
108
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
109
+ const { ids, hint } = await tmpIdPicker.pickAll(ctx, session, bindings);
110
+ if (ids.length === 0) {
111
+ return `请输入正确的玩家编号`;
112
+ }
113
+ const results = [];
114
+ for (const id of ids) {
115
+ const r = await module.exports(ctx, cfg, session, id);
116
+ if (r != null) results.push(r);
117
+ }
118
+ if (hint) {
119
+ await session.send(hint).catch(() => { });
120
+ }
121
+ return results.length === 1 ? results[0] : results;
122
+ }
93
123
  // 并行请求玩家信息与线上状态(互不依赖,避免串行等待)
94
124
  const [playerInfo, playerMapInfo] = await Promise.all([
95
125
  evmOpenApi.playerInfo(ctx.http, tmpId),
@@ -4,7 +4,7 @@ const common = require('../util/common');
4
4
  const evmOpenApi = require('../api/evmOpenApi');
5
5
  const guildBind = require('../database/guildBind');
6
6
  const tmpIdPicker = require('../util/tmpIdPicker');
7
- module.exports = async (ctx, cfg, session, rankingType) => {
7
+ module.exports = async (ctx, cfg, session, rankingType, target) => {
8
8
  if (!ctx.puppeteer) {
9
9
  return '未启用 Puppeteer 功能';
10
10
  }
@@ -20,17 +20,25 @@ module.exports = async (ctx, cfg, session, rankingType) => {
20
20
  else if (mileageRankingList.data.length === 0) {
21
21
  return '暂无数据';
22
22
  }
23
- // 从排行榜中查找当前玩家的排行信息(多绑定时先单选一个,排行榜只高亮一个人)
24
- const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
25
- let bindTmpId = null;
26
- if (bindings.length === 1) {
27
- bindTmpId = String(bindings[0].tmp_id);
28
- } else if (bindings.length > 1) {
29
- const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowMulti: false });
30
- if (picked === null) {
31
- return; // 已列出序号等待用户回复
23
+ // 支持 @别人 查询:拿对方的绑定来高亮(排行榜只高亮一个人)
24
+ let ownerKey = session.userId;
25
+ let ownerName = '';
26
+ if (target && typeof target === 'string' && target.includes('<at ')) {
27
+ const ats = tmpIdPicker.parseAt(target);
28
+ const atUser = (Array.isArray(ats) && ats.length > 0) ? ats[0] : null;
29
+ if (!atUser || !atUser.id) {
30
+ return `无法识别@的用户`;
32
31
  }
33
- bindTmpId = picked[0];
32
+ ownerKey = atUser.id;
33
+ ownerName = atUser.name || '';
34
+ if (ownerName) tmpIdPicker.cacheAtName(session.platform, ownerKey, ownerName);
35
+ }
36
+ // 排行榜只高亮一个人:直接用默认绑定,不弹序号询问
37
+ const bindings = await guildBind.listOf(ctx.database, session.platform, ownerKey);
38
+ let bindTmpId = null;
39
+ if (bindings.length > 0) {
40
+ await guildBind.ensureNames(ctx, bindings);
41
+ bindTmpId = String((bindings.find(b => guildBind.isDefault(b)) || bindings[0]).tmp_id);
34
42
  }
35
43
  let playerMileageRanking = null;
36
44
  if (bindTmpId) {
@@ -1,24 +1,49 @@
1
+ const evmOpenApi = require('../api/evmOpenApi');
1
2
  /**
2
- * TMP ID 绑定(支持一人绑定多个)
3
+ * TMP ID 绑定(支持一人绑定多个 + 默认绑定)
3
4
  * 表 tmp_guild_bind:一个绑定一行,同一 (platform, user_id) 可有多行;
4
- * get() 返回第一行(默认绑定),保持与旧版行为兼容。
5
+ * is_default=1 的那行是默认绑定(同一用户至多一个),排行榜等指令优先使用它。
6
+ * get() 返回默认绑定(无默认时退回第一行),保持与旧版行为兼容。
5
7
  */
6
8
  module.exports = {
7
9
  /**
8
- * 获取默认绑定(第一行,兼容旧逻辑)
10
+ * 补全缺失的玩家名(旧版本绑定的数据没有 tmp_name):查接口并写回数据库。
11
+ * 并发补查,单个失败保持未知(下次遇到再补)。
12
+ * @returns 原数组(行对象已被就地更新)
13
+ */
14
+ async ensureNames(ctx, rows) {
15
+ const missing = (rows || []).filter(r => !r.tmp_name);
16
+ if (missing.length === 0) {
17
+ return rows;
18
+ }
19
+ await Promise.all(missing.map(async (row) => {
20
+ try {
21
+ const info = await evmOpenApi.playerInfo(ctx.http, row.tmp_id);
22
+ if (!info.error && info.data && info.data.name) {
23
+ row.tmp_name = info.data.name;
24
+ await ctx.database.set('tmp_guild_bind', row.id, { tmp_name: info.data.name });
25
+ }
26
+ } catch { } // 查询失败保持未知,下次遇到再补
27
+ }));
28
+ return rows;
29
+ },
30
+ /** 是否默认绑定(is_default 兼容 1 / '1' / true 存法) */
31
+ isDefault(row) {
32
+ if (!row) return false;
33
+ return row.is_default === 1 || row.is_default === '1' || row.is_default === true;
34
+ },
35
+ /**
36
+ * 获取默认绑定:优先 is_default=1 的那行,没有则退回第一行(兼容旧数据)。
9
37
  * @param db 数据源
10
38
  * @param platform 平台
11
39
  * @param userId 用户编号
12
40
  */
13
41
  async get(db, platform, userId) {
14
- const guildBindList = await db.get('tmp_guild_bind', {
15
- platform,
16
- user_id: userId
17
- });
18
- if (guildBindList && guildBindList.length > 0) {
19
- return guildBindList[0];
42
+ const rows = await this.list(db, platform, userId);
43
+ if (rows.length === 0) {
44
+ return null;
20
45
  }
21
- return null;
46
+ return rows.find(r => this.isDefault(r)) || rows[0];
22
47
  },
23
48
  /**
24
49
  * 获取全部绑定(按绑定先后排序)
@@ -34,34 +59,114 @@ module.exports = {
34
59
  }
35
60
  return guildBindList.sort((a, b) => a.id - b.id);
36
61
  },
62
+ /**
63
+ * 获取指定用户(可含平台前缀,如 qq:123)的全部绑定。
64
+ * 用于 @别人 查询:内部统一去掉 `qq:` 这类前缀再匹配,兼容库里两种存法。
65
+ */
66
+ async listOf(db, platform, userId) {
67
+ const bare = String(userId == null ? '' : userId).replace(/^[a-z]+:/i, '');
68
+ const rows = await db.get('tmp_guild_bind', { platform });
69
+ return (rows || [])
70
+ .filter(r => {
71
+ const uid = String(r.user_id == null ? '' : r.user_id).replace(/^[a-z]+:/i, '');
72
+ return uid === bare;
73
+ })
74
+ .sort((a, b) => a.id - b.id);
75
+ },
76
+ /**
77
+ * 取默认绑定的 tmpId(无绑定时返回 null)
78
+ * @returns {string|null}
79
+ */
80
+ async getDefaultTmpId(db, platform, userId) {
81
+ const row = await this.get(db, platform, userId);
82
+ return row ? String(row.tmp_id) : null;
83
+ },
84
+ /**
85
+ * 把某个绑定设为该用户的默认绑定。同一用户的其他行会被清掉 is_default,
86
+ * 保证「至多一个默认」;若此前没有任何默认,默认绑定位本身就包含「第一条」。
87
+ * @param id 绑定行主键
88
+ * @returns { row, changed } changed=false 表示本来就是默认
89
+ */
90
+ async setDefault(ctx, db, platform, userId, id) {
91
+ const rows = await this.list(db, platform, userId);
92
+ const target = rows.find(r => r.id === id);
93
+ if (!target) {
94
+ return { row: null, changed: false };
95
+ }
96
+ if (this.isDefault(target)) {
97
+ return { row: target, changed: false };
98
+ }
99
+ // 并发清掉其他行的默认标记(含历史脏数据里可能存在的多个默认)
100
+ await Promise.all(rows
101
+ .filter(r => r.id !== id && this.isDefault(r))
102
+ .map(r => db.set('tmp_guild_bind', r.id, { is_default: 0 })));
103
+ await db.set('tmp_guild_bind', id, { is_default: 1 });
104
+ target.is_default = 1;
105
+ for (const r of rows) {
106
+ if (r.id !== id) r.is_default = 0;
107
+ }
108
+ return { row: target, changed: true };
109
+ },
110
+ /**
111
+ * 按序号把该用户的某个绑定设为默认(序号即「我的绑定」列表里的序号)
112
+ * @returns { row, changed } row=null 表示序号越界
113
+ */
114
+ async setDefaultByIndex(ctx, db, platform, userId, index) {
115
+ const rows = await this.list(db, platform, userId);
116
+ const idx = parseInt(index, 10);
117
+ if (isNaN(idx) || idx < 1 || idx > rows.length) {
118
+ return { row: null, changed: false, total: rows.length };
119
+ }
120
+ const result = await this.setDefault(ctx, db, platform, userId, rows[idx - 1].id);
121
+ return { ...result, total: rows.length };
122
+ },
37
123
  /**
38
124
  * 追加绑定(同一 tmpId 重复绑定时不会重复入库)
125
+ * 第一个绑定自动成为默认绑定。
39
126
  * @param tmpId TMP ID
40
127
  * @param tmpName TMP 玩家名称(用于多绑定列表展示,可为 null)
41
- * @returns { exists, row } exists=true 表示该 tmpId 已绑定过
128
+ * @returns { exists, row, isDefault } exists=true 表示该 tmpId 已绑定过
42
129
  */
43
130
  async add(db, platform, userId, tmpId, tmpName) {
44
131
  const guildBindList = await this.list(db, platform, userId);
45
132
  const existed = guildBindList.find(row => String(row.tmp_id) === String(tmpId));
46
133
  if (existed) {
47
- return { exists: true, row: existed };
134
+ return { exists: true, row: existed, isDefault: this.isDefault(existed) };
48
135
  }
136
+ // 还没有任何默认绑定(含首次绑定、以及历史数据全无标记)时,新绑定即默认
137
+ const needDefault = !guildBindList.some(r => this.isDefault(r));
49
138
  const row = await db.create('tmp_guild_bind', {
50
139
  platform: platform,
51
140
  user_id: userId,
52
141
  tmp_id: tmpId,
53
- tmp_name: tmpName || null
142
+ tmp_name: tmpName || null,
143
+ is_default: needDefault ? 1 : 0
54
144
  });
55
- return { exists: false, row };
145
+ return { exists: false, row, isDefault: needDefault };
56
146
  },
57
147
  /**
58
- * 删除指定主键的绑定
148
+ * 删除指定主键的绑定;若删掉的正是默认绑定,自动把剩下的第一条顶为默认,
149
+ * 避免「有绑定却没有默认」的空档。
150
+ * @returns { removedDefault, promoted } promoted=被顶为默认的那行(无则 null)
59
151
  */
60
- async remove(db, id) {
152
+ async remove(db, platform, userId, id) {
153
+ const rows = await this.list(db, platform, userId);
154
+ const target = rows.find(r => r.id === id);
155
+ const wasDefault = this.isDefault(target);
61
156
  await db.remove('tmp_guild_bind', id);
157
+ if (!wasDefault) {
158
+ return { removedDefault: false, promoted: null };
159
+ }
160
+ const rest = rows.filter(r => r.id !== id);
161
+ if (rest.length === 0) {
162
+ return { removedDefault: true, promoted: null };
163
+ }
164
+ await db.set('tmp_guild_bind', rest[0].id, { is_default: 1 });
165
+ rest[0].is_default = 1;
166
+ return { removedDefault: true, promoted: rest[0] };
62
167
  },
63
168
  /**
64
- * 删除指定用户的全部绑定
169
+ * 删除指定用户的全部绑定(默认标记随行一起删掉,无需额外清理)
65
170
  */
66
171
  async removeAll(db, platform, userId) {
67
172
  await db.remove('tmp_guild_bind', {
@@ -32,6 +32,12 @@ const modelArray = {
32
32
  length: 100,
33
33
  nullable: true,
34
34
  comment: 'TMP玩家名称(绑定时记录,用于多绑定列表展示)'
35
+ },
36
+ is_default: {
37
+ type: 'unsigned',
38
+ length: 1,
39
+ nullable: true,
40
+ comment: '是否默认绑定:1=默认(排行榜等用到),0/null=普通'
35
41
  }
36
42
  },
37
43
  tmp_translate_cache: {
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ exports.apply = apply;
5
5
 
6
6
  const koishi_1 = require("koishi");
7
7
  const model = require('./database/model');
8
+ const guildBind = require('./database/guildBind');
8
9
  const { ServerType } = require('./util/constant');
9
10
  const { MileageRankingType } = require('./util/constant');
10
11
  const { LeadRoleCallMessage, LeadRoleCallTime } = require('./util/constant');
@@ -263,14 +264,43 @@ function registerBaseCommands(ctx, cfg) {
263
264
  .action(async ({ session }, tmpId) => await commands.tmpBind(ctx, cfg, session, tmpId));
264
265
 
265
266
  ctx.command('我的绑定')
266
- .usage("查看已绑定的全部TMP编号")
267
+ .usage("查看已绑定的全部TMP编号(★ 为默认绑定)")
267
268
  .action(async ({ session }) => {
268
269
  const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
269
270
  if (bindings.length === 0) {
270
271
  return '还没有绑定编号,使用「绑定 tmpId」添加';
271
272
  }
272
- const lines = bindings.map((b, i) => `${i + 1}. ${b.tmp_name || '未知'} (${b.tmp_id})`);
273
- return `已绑定 ${bindings.length} 个编号:\n${lines.join('\n')}${bindings.length > 1 ? '\n查询时会列出序号供选择;使用「解绑 序号」可删除' : ''}`;
273
+ // 旧绑定缺名字的先补查并写回
274
+ await guildBind.ensureNames(ctx, bindings);
275
+ const lines = bindings.map((b, i) => {
276
+ const mark = guildBind.isDefault(b) ? '★ ' : ' ';
277
+ return `${mark}${i + 1}. ${b.tmp_name || '未知'} (${b.tmp_id})`;
278
+ });
279
+ if (bindings.length === 1) {
280
+ return `已绑定 ${bindings.length} 个编号:\n${lines.join('\n')}`;
281
+ }
282
+ return `已绑定 ${bindings.length} 个编号:\n${lines.join('\n')}\n★ 为默认绑定(排行榜使用);「设为默认 序号」可切换,「解绑 序号」可删除\n查询回复序号选择,回复 all 一次性查全部(定位/足迹除外)`;
283
+ });
284
+
285
+ ctx.command('设为默认 <index:string>')
286
+ .usage("把某个绑定设为默认(序号见「我的绑定」),排行榜等指令将使用默认绑定")
287
+ .example("设为默认 2")
288
+ .action(async ({ session }, index) => {
289
+ if (!index || !index.trim()) {
290
+ return '请输入要设为默认的序号(序号见「我的绑定」)';
291
+ }
292
+ await guildBind.ensureNames(ctx, await guildBind.list(ctx.database, session.platform, session.userId));
293
+ const { row, changed, total } = await guildBind.setDefaultByIndex(
294
+ ctx, ctx.database, session.platform, session.userId, index.trim()
295
+ );
296
+ if (!row) {
297
+ if (!total) {
298
+ return '还没有绑定编号,使用「绑定 tmpId」添加';
299
+ }
300
+ return `序号无效,可用范围 1-${total}(序号见「我的绑定」)`;
301
+ }
302
+ const label = `${row.tmp_name || '未知'} (${row.tmp_id})`;
303
+ return changed ? `已将 ${label} 设为默认绑定` : `${label} 已经是默认绑定`;
274
304
  });
275
305
 
276
306
  ctx.command('解绑 <index:string>')
@@ -282,6 +312,7 @@ function registerBaseCommands(ctx, cfg) {
282
312
  if (bindings.length === 0) {
283
313
  return '还没有绑定编号';
284
314
  }
315
+ await guildBind.ensureNames(ctx, bindings);
285
316
  if (!index) {
286
317
  return '请输入要解绑的序号或 all(序号见「我的绑定」)';
287
318
  }
@@ -294,8 +325,12 @@ function registerBaseCommands(ctx, cfg) {
294
325
  return `序号无效,可用范围 1-${bindings.length}(序号见「我的绑定」)`;
295
326
  }
296
327
  const row = bindings[idx - 1];
297
- await guildBind.remove(ctx.database, row.id);
298
- return `已解绑 ${idx}. ${row.tmp_name || '未知'} (${row.tmp_id})`;
328
+ const { promoted } = await guildBind.remove(ctx.database, session.platform, session.userId, row.id);
329
+ let msg = `已解绑 ${idx}. ${row.tmp_name || '未知'} (${row.tmp_id})`;
330
+ if (promoted) {
331
+ msg += `\n默认绑定已自动切换到 ${promoted.tmp_name || '未知'} (${promoted.tmp_id})`;
332
+ }
333
+ return msg;
299
334
  });
300
335
  }
301
336
 
@@ -325,23 +360,23 @@ function registerBaseCommands(ctx, cfg) {
325
360
  }
326
361
 
327
362
  if (cfg.commands?.tmpMileageRanking) {
328
- ctx.command('里程排行榜')
329
- .usage("查询欧洲卡车模拟2里程排行榜")
330
- .action(async ({ session }) => await commands.tmpMileageRanking(ctx, session, MileageRankingType.total));
363
+ ctx.command('里程排行榜 [target]')
364
+ .usage("查询欧洲卡车模拟2里程排行榜(可 @某人 查看对方的排名)")
365
+ .action(async ({ session }, target) => await commands.tmpMileageRanking(ctx, session, MileageRankingType.total, target));
331
366
 
332
- ctx.command('今日里程排行榜')
333
- .usage("查询欧洲卡车模拟2今日里程排行榜")
334
- .action(async ({ session }) => await commands.tmpMileageRanking(ctx, session, MileageRankingType.today));
367
+ ctx.command('今日里程排行榜 [target]')
368
+ .usage("查询欧洲卡车模拟2今日里程排行榜(可 @某人 查看对方的排名)")
369
+ .action(async ({ session }, target) => await commands.tmpMileageRanking(ctx, session, MileageRankingType.today, target));
335
370
  }
336
371
 
337
372
  if (cfg.commands?.tmpVtcMileageRanking) {
338
- ctx.command('vtc里程排行榜')
339
- .usage("查询车队总里程排行榜")
340
- .action(async ({ session }) => await commands.tmpVtcMileageRanking(ctx, cfg, session, MileageRankingType.total));
373
+ ctx.command('vtc里程排行榜 [target]')
374
+ .usage("查询车队总里程排行榜(可 @某人 查看对方的排名)")
375
+ .action(async ({ session }, target) => await commands.tmpVtcMileageRanking(ctx, cfg, session, MileageRankingType.total, target));
341
376
 
342
- ctx.command('vtc今日里程排行榜')
343
- .usage("查询车队今日里程排行榜")
344
- .action(async ({ session }) => await commands.tmpVtcMileageRanking(ctx, cfg, session, MileageRankingType.today));
377
+ ctx.command('vtc今日里程排行榜 [target]')
378
+ .usage("查询车队今日里程排行榜(可 @某人 查看对方的排名)")
379
+ .action(async ({ session }, target) => await commands.tmpVtcMileageRanking(ctx, cfg, session, MileageRankingType.today, target));
345
380
  }
346
381
 
347
382
  if (cfg.commands?.pointRanking) {
@@ -51,13 +51,13 @@ let mapConfig = {
51
51
  ]
52
52
  }
53
53
  },
54
- // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json
55
- // (游戏坐标 x∈[-120098.891, 41144.094] y∈[-79127.37, 82115.62] 线性铺满 131072 网格)
54
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json(tmp-ats-promods)
55
+ // (游戏坐标 x∈[-120835.742, 41880.945] y∈[-87127.17, 75589.516] 线性铺满 131072 网格)
56
56
  ats: {
57
- tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-yellow/Tiles/{z}/{x}/{y}.png',
57
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-promods-yellow/Tiles/{z}/{x}/{y}.png',
58
58
  multipliers: {
59
- x: 97626.58,
60
- y: 64321.45
59
+ x: 97335.94,
60
+ y: 70182.92
61
61
  },
62
62
  breakpoints: {},
63
63
  bounds: {
@@ -69,8 +69,8 @@ let mapConfig = {
69
69
  // 游戏地转地图坐标
70
70
  calculateMapCoordinate (x, y) {
71
71
  return [
72
- x / 1.230185 + mapConfig.ats.multipliers.x,
73
- y / 1.230185 + mapConfig.ats.multipliers.y
72
+ x / 1.2414298 + mapConfig.ats.multipliers.x,
73
+ y / 1.2414298 + mapConfig.ats.multipliers.y
74
74
  ];
75
75
  }
76
76
  }
@@ -171,6 +171,28 @@
171
171
  y / 2.598541 + mapConfig.promods.multipliers.y
172
172
  ]
173
173
  }
174
+ },
175
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json(tmp-ats-promods)
176
+ ats: {
177
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-promods-yellow/Tiles/{z}/{x}/{y}.png',
178
+ multipliers: {
179
+ x: 97335.94,
180
+ y: 70182.92
181
+ },
182
+ breakpoints: {},
183
+ bounds: {
184
+ y: 131072,
185
+ x: 131072
186
+ },
187
+ maxZoom: 8,
188
+ minZoom: 0,
189
+ // 游戏地转地图坐标
190
+ calculateMapCoordinate (x, y) {
191
+ return [
192
+ x / 1.2414298 + mapConfig.ats.multipliers.x,
193
+ y / 1.2414298 + mapConfig.ats.multipliers.y
194
+ ];
195
+ }
174
196
  }
175
197
  }
176
198
 
@@ -1046,6 +1046,18 @@
1046
1046
  calculateMapCoordinate (x, y) {
1047
1047
  return [x / 2.598541 + mapConfig.promods.multipliers.x, y / 2.598541 + mapConfig.promods.multipliers.y];
1048
1048
  }
1049
+ },
1050
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json(tmp-ats-promods)
1051
+ ats: {
1052
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-promods-yellow/Tiles/{z}/{x}/{y}.png',
1053
+ multipliers: { x: 97335.94, y: 70182.92 },
1054
+ breakpoints: {},
1055
+ bounds: { y: 131072, x: 131072 },
1056
+ maxZoom: 8,
1057
+ minZoom: 0,
1058
+ calculateMapCoordinate (x, y) {
1059
+ return [x / 1.2414298 + mapConfig.ats.multipliers.x, y / 1.2414298 + mapConfig.ats.multipliers.y];
1060
+ }
1049
1061
  }
1050
1062
  }
1051
1063
 
@@ -265,15 +265,15 @@
265
265
  return [x / 2.598541 + mapConfig.promods.multipliers.x, y / 2.598541 + mapConfig.promods.multipliers.y];
266
266
  }
267
267
  },
268
- // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json
268
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json(tmp-ats-promods)
269
269
  ats: {
270
- tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-yellow/Tiles/{z}/{x}/{y}.png',
271
- multipliers: { x: 97626.58, y: 64321.45 },
270
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-promods-yellow/Tiles/{z}/{x}/{y}.png',
271
+ multipliers: { x: 97335.94, y: 70182.92 },
272
272
  breakpoints: {},
273
273
  bounds: { y: 131072, x: 131072 },
274
274
  maxZoom: 8, minZoom: 0,
275
275
  calculateMapCoordinate(x, y) {
276
- return [x / 1.230185 + mapConfig.ats.multipliers.x, y / 1.230185 + mapConfig.ats.multipliers.y];
276
+ return [x / 1.2414298 + mapConfig.ats.multipliers.x, y / 1.2414298 + mapConfig.ats.multipliers.y];
277
277
  }
278
278
  }
279
279
  };
@@ -1,10 +1,60 @@
1
1
  /**
2
- * 多绑定选择器:用户绑定了多个 tmpId 时,列出序号让用户回复选择(支持多选)。
2
+ * 多绑定选择器:需要选 tmpId 时列出序号让用户回复选择(支持多选)。
3
3
  * 原理:需要选择的指令先回复编号列表并挂起(pending),由前置中间件捕获用户下一条
4
4
  * "纯序号"消息后解析并继续执行;超时/取消/其他内容均视为放弃。
5
+ *
6
+ * 归属:挂起只认**发起指令的人**。@别人 查询时列表显示被 @ 的人(以及对方的绑定),
7
+ * 但回复序号仍必须由发起者本人来发;被 @ 的人或群里其他人回复不会被当作选择。
5
8
  */
6
9
  const MAX_PICK = 5; // 单次多选上限
7
10
  const PICK_TIMEOUT = 60_000; // 等待回复时长(毫秒)
11
+ /** 「全部」哨兵:回复 all / 全部,或把 all 当作参数传入时使用 */
12
+ const ALL = Symbol('tmpIdPicker.ALL');
13
+ const guildBind = require('../database/guildBind');
14
+
15
+ /**
16
+ * 判断一个参数是不是「全部」写法(all 大小写不敏感,也接受「全部」)
17
+ */
18
+ function isAllArg(value) {
19
+ if (typeof value !== 'string') return false;
20
+ const v = value.trim();
21
+ return /^all$/i.test(v) || v === '全部';
22
+ }
23
+
24
+ /**
25
+ * 把「全部绑定」解析成 tmpId 数组。截断规则与多选一致(最多 MAX_PICK 个)。
26
+ * @returns { ids: string[], total: number, truncated: boolean }
27
+ */
28
+ function resolveAll(bindings, max = MAX_PICK) {
29
+ const list = bindings || [];
30
+ const ids = list.slice(0, max).map(b => String(b.tmp_id));
31
+ return { ids, total: list.length, truncated: list.length > ids.length };
32
+ }
33
+
34
+ /**
35
+ * 供指令层处理 `xxx all` 写法:统一取「本人全部绑定」并补全名字。
36
+ * @returns {
37
+ * ids: string[], 要逐个查询的 tmpId(已截断到 MAX_PICK)
38
+ * total: number, 绑定总数
39
+ * truncated: boolean, 是否因上限被截断
40
+ * hint: string|null, 需要附加提示时的文案(截断 / 无绑定),无则 null
41
+ * }
42
+ */
43
+ async function pickAll(ctx, session, bindingRows) {
44
+ const list = bindingRows || [];
45
+ if (list.length === 0) {
46
+ return { ids: [], total: 0, truncated: false, hint: null };
47
+ }
48
+ // 补全旧数据缺失的玩家名(复用列表展示那套逻辑,保证提示文案准确)
49
+ await guildBind.ensureNames(ctx, list);
50
+ const { ids, total, truncated } = resolveAll(list);
51
+ return {
52
+ ids,
53
+ total,
54
+ truncated,
55
+ hint: truncated ? `共 ${total} 个绑定,本次查询前 ${ids.length} 个` : null,
56
+ };
57
+ }
8
58
 
9
59
  /**
10
60
  * key: `${platform}:${channelId}:${userId}` → { bindings, allowMulti, max, finish }
@@ -12,12 +62,77 @@ const PICK_TIMEOUT = 60_000; // 等待回复时长(毫秒)
12
62
  */
13
63
  const pending = new Map();
14
64
 
65
+ /**
66
+ * 被 @ 的成员 id → { platform:id → 展示名 }。会话文本里的 @ 段通常带 name,
67
+ * 缺失时(纯 <at id="..."/>)从事件成员列表兜底拿昵称。
68
+ */
69
+ const atNameCache = new Map();
70
+
71
+ /** QQ 号与 koishi 的 `qq:` 前缀可能不一致,统一成纯数字做 key */
72
+ function normalizeId(id) {
73
+ return String(id == null ? '' : id).replace(/^[a-z]+:/i, '');
74
+ }
75
+
76
+ /**
77
+ * 从消息文本/元素数组里遍历出所有被 @ 的人。
78
+ * 解析失败时返回 { failed: true },由调用方决定是否降级为默认绑定。
79
+ * @returns {Array<{ id: string, name: string }> | { failed: true }}
80
+ */
81
+ function parseAt(target) {
82
+ if (target == null) return { failed: true };
83
+ if (Array.isArray(target)) {
84
+ const els = target.filter(el => el && typeof el === 'object' && el.type === 'at' && el.attrs && el.attrs.id != null);
85
+ if (els.length === 0) return { failed: true };
86
+ return els.map(el => ({ id: String(el.attrs.id), name: el.attrs.name || '' }));
87
+ }
88
+ const text = String(target);
89
+ if (!text.includes('<at ')) return [];
90
+ const out = [];
91
+ const re = /<at\s+([^>]*)>/g;
92
+ let m;
93
+ while ((m = re.exec(text)) !== null) {
94
+ const attrs = m[1];
95
+ const idm = /\bid\s*=\s*"([^"]*)"/.exec(attrs);
96
+ const nm = /\bname\s*=\s*"([^"]*)"/.exec(attrs);
97
+ if (idm && idm[1]) out.push({ id: idm[1], name: nm ? nm[1] : '' });
98
+ }
99
+ // 有 <at 标记却一个都没解析出来 → 格式异常,交调用方降级
100
+ if (out.length === 0) return { failed: true };
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * 取 @ 对象的展示名(仅用于 @别人 时把对方名字写进列表标题)
106
+ */
107
+ function displayName(session, at) {
108
+ if (at.name) return at.name;
109
+ for (const key of ['member', 'author', 'user']) {
110
+ const obj = session[key];
111
+ if (obj && normalizeId(obj.id) === normalizeId(at.id) && (obj.nick || obj.name)) {
112
+ return obj.nick || obj.name;
113
+ }
114
+ }
115
+ if (session.event && Array.isArray(session.event.member)) {
116
+ const hit = session.event.member.find(mm => normalizeId(mm.user && mm.user.id) === normalizeId(at.id));
117
+ if (hit && hit.user && (hit.user.nick || hit.user.name)) {
118
+ return hit.user.nick || hit.user.name;
119
+ }
120
+ }
121
+ const sk = session.platform + ':' + normalizeId(at.id);
122
+ const cached = atNameCache.get(sk);
123
+ return cached || normalizeId(at.id);
124
+ }
125
+
15
126
  function keyOf(session) {
16
127
  return `${session.platform}:${session.channelId || 'private'}:${session.userId}`;
17
128
  }
18
129
 
19
130
  /**
20
131
  * 注册选择器中间件(在插件 apply 中调用一次,前置执行)
132
+ *
133
+ * 归属规则:挂起记录只挂在**发起指令的那个人**的 key 上(keyOf 里的 session.userId)。
134
+ * 所以「A 发 查询 @B」时,只有 A 回复序号才算数;B 或群里其他人回复一律放行给原指令,
135
+ * 不会替 A 做选择。
21
136
  */
22
137
  function register(ctx) {
23
138
  ctx.middleware((session, next) => {
@@ -30,6 +145,21 @@ function register(ctx) {
30
145
  record.finish(null);
31
146
  return;
32
147
  }
148
+ // 「全部」:选中全部绑定(同样受 max 截断,超出时提示)
149
+ // 不发图/重负载的指令(如足迹)会把 allowAll 置 false,此分支退回按普通内容处理
150
+ if (record.allowAll !== false && isAllArg(content)) {
151
+ const { ids, total, truncated } = resolveAll(record.bindings, record.allowMulti ? record.max : 1);
152
+ if (ids.length === 0) {
153
+ record.finish(null);
154
+ session.send('没有可查询的绑定,已取消').catch(() => { });
155
+ return;
156
+ }
157
+ if (truncated) {
158
+ session.send(`共 ${total} 个绑定,本次查询前 ${ids.length} 个`).catch(() => { });
159
+ }
160
+ record.finish(ids);
161
+ return;
162
+ }
33
163
  // 纯序号消息(支持 1,3 / 1 3 / 1、3 等分隔写法)
34
164
  if (/^\d{1,6}(?:\s*[,,、 ]\s*\d{1,6})*$/.test(content)) {
35
165
  const indexes = [...content.matchAll(/\d{1,6}/g)].map(m => parseInt(m[0], 10));
@@ -53,25 +183,43 @@ function register(ctx) {
53
183
 
54
184
  /**
55
185
  * 需要绑定 tmpId 的指令调用:绑定数 ≤1 时直接返回不打扰,≥2 时列出序号并等待选择。
56
- * @param bindings guildBind.list() 的结果
57
- * @param opts { allowMulti?: boolean } 是否允许多选(默认允许)
186
+ * 挂起只认调用本方法的会话(发起者),别人回复无效。
187
+ * @param bindings guildBind.list() / listOf() 的结果
188
+ * @param opts {
189
+ * allowMulti?: boolean, 是否允许多选(默认允许)
190
+ * allowAll?: boolean, 是否允许回复 all 一次选全部(默认允许);
191
+ * 出图/重负载指令(足迹)传 false —— 避免一次请求打满服务器
192
+ * ownerName?: string, 列表标题里显示的名字(@别人 查询时用;仅影响文案)
193
+ * }
58
194
  * @returns 选中的 tmpId 字符串数组;返回 null 表示已在等待用户回复,调用方应直接 return
59
195
  */
60
196
  async function pick(ctx, session, bindings, opts = {}) {
61
197
  if (!bindings || bindings.length === 0) return [];
198
+ // 旧绑定的行没有名字:先查接口补全并写回,避免列表显示"未知"
199
+ await guildBind.ensureNames(ctx, bindings);
62
200
  if (bindings.length === 1) return [String(bindings[0].tmp_id)];
63
201
  const allowMulti = opts.allowMulti !== false;
202
+ const allowAll = opts.allowAll !== false;
64
203
  const max = allowMulti ? MAX_PICK : 1;
204
+ // @别人 查询:对方的名字(提到过就复用,否则用 id 代替)
205
+ let ownerName = opts.ownerName || '';
206
+ if (!ownerName && opts.ownerKey && String(opts.ownerKey) !== session.userId) {
207
+ ownerName = atNameCache.get(session.platform + ':' + normalizeId(opts.ownerKey)) || normalizeId(opts.ownerKey);
208
+ }
65
209
  const lines = bindings.map((b, i) => `${i + 1}. ${b.tmp_name || '未知'} (${b.tmp_id})`);
66
210
  const tip = allowMulti
67
211
  ? `请回复序号选择(最多 ${max} 个,多选用逗号分隔,如 1,3)`
212
+ + (allowAll ? ',或回复 all 查询全部' : '')
68
213
  : '请回复一个序号';
69
- await session.send(`您绑定了多个编号:\n${lines.join('\n')}\n${tip}\n回复"取消"放弃,60 秒内有效`);
214
+ const prefix = ownerName ? `@${ownerName} 绑定了多个编号:` : '您绑定了多个编号:';
215
+ await session.send(`${prefix}\n${lines.join('\n')}\n${tip}\n回复"取消"放弃,60 秒内有效`);
70
216
  return await new Promise(resolve => {
217
+ // key 里带的是 session.userId(发起者),所以只有发起者能回复
71
218
  const key = keyOf(session);
72
219
  const record = {
73
220
  bindings,
74
221
  allowMulti,
222
+ allowAll,
75
223
  max,
76
224
  timer: null,
77
225
  finish(result) {
@@ -91,4 +239,11 @@ async function pick(ctx, session, bindings, opts = {}) {
91
239
  });
92
240
  }
93
241
 
94
- module.exports = { register, pick, MAX_PICK, PICK_TIMEOUT };
242
+ module.exports = {
243
+ register, pick, MAX_PICK, PICK_TIMEOUT,
244
+ parseAt, normalizeId,
245
+ isAllArg, resolveAll, ALL, pickAll,
246
+ cacheAtName(platform, id, name) {
247
+ if (platform && id != null && name) atNameCache.set(platform + ':' + normalizeId(id), name);
248
+ },
249
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ets2-tools-tmp",
3
3
  "description": "欧卡2 TruckersMP信息查询、车队平台查询及活动提醒",
4
- "version": "3.4.1",
4
+ "version": "3.4.3",
5
5
  "contributors": [
6
6
  "opwop <slhp1013@qq.com>"
7
7
  ],