fix: 修正 QQBot FFLogs 国服查询
This commit is contained in:
parent
a85db5e045
commit
b1249517e8
@ -84,10 +84,10 @@ FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2
|
|||||||
FF14_DEFAULT_WORLD=中国
|
FF14_DEFAULT_WORLD=中国
|
||||||
FF14_MARKET_CACHE_TTL_MS=60000
|
FF14_MARKET_CACHE_TTL_MS=60000
|
||||||
|
|
||||||
FFLOGS_BASE_URL=https://www.fflogs.com
|
FFLOGS_BASE_URL=https://cn.fflogs.com
|
||||||
FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
||||||
FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
|
FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
|
||||||
FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token
|
FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
|
||||||
FFLOGS_CLIENT_ID=
|
FFLOGS_CLIENT_ID=
|
||||||
FFLOGS_CLIENT_SECRET=
|
FFLOGS_CLIENT_SECRET=
|
||||||
FFLOGS_DEFAULT_SERVER_REGION=CN
|
FFLOGS_DEFAULT_SERVER_REGION=CN
|
||||||
|
|||||||
@ -16,10 +16,10 @@ ENV LOKI_BATCH_INTERVAL_SECONDS=5
|
|||||||
ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000
|
ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000
|
||||||
ENV LOKI_QUERY_MAX_LIMIT=1000
|
ENV LOKI_QUERY_MAX_LIMIT=1000
|
||||||
ENV LOKI_SILENCE_ERRORS=true
|
ENV LOKI_SILENCE_ERRORS=true
|
||||||
ENV FFLOGS_BASE_URL=https://www.fflogs.com
|
ENV FFLOGS_BASE_URL=https://cn.fflogs.com
|
||||||
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
||||||
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
|
ENV FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
|
||||||
ENV FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token
|
ENV FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
|
||||||
ENV FFLOGS_DEFAULT_SERVER_REGION=CN
|
ENV FFLOGS_DEFAULT_SERVER_REGION=CN
|
||||||
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000
|
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000
|
||||||
|
|
||||||
|
|||||||
@ -545,11 +545,6 @@ ON DUPLICATE KEY UPDATE
|
|||||||
|
|
||||||
INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
|
INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
|
||||||
VALUES
|
VALUES
|
||||||
(2041700000000300601, 'FFLOGS_ENCOUNTER_LABEL', 'M9S Vamp Fatale', 'vampfatale', NULL, 1, 1),
|
|
||||||
(2041700000000300602, 'FFLOGS_ENCOUNTER_LABEL', 'M10S Red Hot and Deep Blue', 'redhotanddeepblue', NULL, 2, 1),
|
|
||||||
(2041700000000300603, 'FFLOGS_ENCOUNTER_LABEL', 'M11S The Tyrant', 'thetyrant', NULL, 3, 1),
|
|
||||||
(2041700000000300604, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P1 Lindwurm', 'lindwurm', NULL, 4, 1),
|
|
||||||
(2041700000000300605, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P2 Lindwurm II', 'lindwurmii', NULL, 5, 1),
|
|
||||||
(2041700000000300701, 'FFLOGS_JOB_LABEL', '骑士', 'paladin', NULL, 1, 1),
|
(2041700000000300701, 'FFLOGS_JOB_LABEL', '骑士', 'paladin', NULL, 1, 1),
|
||||||
(2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1),
|
(2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1),
|
||||||
(2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 1),
|
(2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 1),
|
||||||
|
|||||||
@ -15,8 +15,6 @@ import {
|
|||||||
import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types';
|
import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types';
|
||||||
import type { QqbotCommandMatchResult } from '../qqbot.types';
|
import type { QqbotCommandMatchResult } from '../qqbot.types';
|
||||||
|
|
||||||
const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotCommandParserService {
|
export class QqbotCommandParserService {
|
||||||
constructor(private readonly dictService: DictService) {}
|
constructor(private readonly dictService: DictService) {}
|
||||||
@ -208,9 +206,8 @@ export class QqbotCommandParserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!encounterName && remainingPositionals.length > 2) {
|
if (!encounterName && remainingPositionals.length > 2) {
|
||||||
const picked = await this.pickFflogsPositionalsByKnownWorld(
|
const picked =
|
||||||
remainingPositionals,
|
await this.pickFflogsPositionalsByKnownWorld(remainingPositionals);
|
||||||
);
|
|
||||||
if (picked) {
|
if (picked) {
|
||||||
characterName = characterName || picked.characterName;
|
characterName = characterName || picked.characterName;
|
||||||
serverSlug = serverSlug || picked.serverSlug;
|
serverSlug = serverSlug || picked.serverSlug;
|
||||||
@ -219,16 +216,6 @@ export class QqbotCommandParserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!encounterName && remainingPositionals.length > 1) {
|
|
||||||
const picked = await this.pickTrailingFflogsEncounter(
|
|
||||||
remainingPositionals,
|
|
||||||
);
|
|
||||||
if (picked) {
|
|
||||||
encounterName = picked.encounterName;
|
|
||||||
remainingPositionals = picked.positionals;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!characterName && remainingPositionals.length) {
|
if (!characterName && remainingPositionals.length) {
|
||||||
const joined = remainingPositionals.join(' ');
|
const joined = remainingPositionals.join(' ');
|
||||||
if (joined.includes('@')) {
|
if (joined.includes('@')) {
|
||||||
@ -350,44 +337,6 @@ export class QqbotCommandParserService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pickTrailingFflogsEncounter(positional: string[]) {
|
|
||||||
const catalog = await this.getFflogsEncounterCatalog();
|
|
||||||
for (let index = 1; index < positional.length; index++) {
|
|
||||||
const encounterName = positional.slice(index).join(' ').trim();
|
|
||||||
if (!this.isFflogsEncounterName(catalog, encounterName)) continue;
|
|
||||||
return {
|
|
||||||
encounterName,
|
|
||||||
positionals: positional.slice(0, index),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getFflogsEncounterCatalog() {
|
|
||||||
const dicts = await this.dictService.getDictItemsByKey(
|
|
||||||
QQBOT_FFLOGS_ENCOUNTER_DICT_CODE,
|
|
||||||
);
|
|
||||||
const keys = new Set<string>();
|
|
||||||
for (const item of dicts) {
|
|
||||||
for (const key of this.buildFflogsLookupKeys(item.label, item.value)) {
|
|
||||||
keys.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isFflogsEncounterName(catalog: Set<string>, value: string) {
|
|
||||||
const keys = this.buildFflogsLookupKeys(value);
|
|
||||||
return keys.some(
|
|
||||||
(key) =>
|
|
||||||
catalog.has(key) ||
|
|
||||||
(key.length >= 3 &&
|
|
||||||
[...catalog].some(
|
|
||||||
(candidate) => candidate.startsWith(key) || candidate.includes(key),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getFf14MarketCatalog() {
|
private async getFf14MarketCatalog() {
|
||||||
const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
|
const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
|
||||||
await this.dictService.relationTree({
|
await this.dictService.relationTree({
|
||||||
@ -441,22 +390,4 @@ export class QqbotCommandParserService {
|
|||||||
if (value === true) return '';
|
if (value === true) return '';
|
||||||
return `${value || ''}`.trim();
|
return `${value || ''}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFflogsLookupKeys(...values: string[]) {
|
|
||||||
const keys = values
|
|
||||||
.flatMap((value) => {
|
|
||||||
const normalized = this.normalizeFflogsLookupKey(value);
|
|
||||||
const withoutAnd = normalized.replace(/and/g, '');
|
|
||||||
return [normalized, withoutAnd];
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
return [...new Set(keys)];
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeFflogsLookupKey(value: string) {
|
|
||||||
return `${value || ''}`
|
|
||||||
.normalize('NFKC')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as http from 'node:http';
|
|||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import { DictService } from '../../../admin/dict/dict.service';
|
import { DictService } from '../../../admin/dict/dict.service';
|
||||||
import type {
|
import type {
|
||||||
|
FflogsCharacterEncounterRankingsResponse,
|
||||||
FflogsCharacterSummaryResponse,
|
FflogsCharacterSummaryResponse,
|
||||||
FflogsEncounterFightCandidate,
|
FflogsEncounterFightCandidate,
|
||||||
FflogsEncounterLookup,
|
FflogsEncounterLookup,
|
||||||
@ -22,13 +23,20 @@ import type {
|
|||||||
} from './qqbot-fflogs.types';
|
} from './qqbot-fflogs.types';
|
||||||
|
|
||||||
const FFLOGS_LOCALIZATION_DICT_CODES = {
|
const FFLOGS_LOCALIZATION_DICT_CODES = {
|
||||||
encounter: 'FFLOGS_ENCOUNTER_LABEL',
|
|
||||||
job: 'FFLOGS_JOB_LABEL',
|
job: 'FFLOGS_JOB_LABEL',
|
||||||
metric: 'FFLOGS_METRIC_LABEL',
|
metric: 'FFLOGS_METRIC_LABEL',
|
||||||
role: 'FFLOGS_ROLE_LABEL',
|
role: 'FFLOGS_ROLE_LABEL',
|
||||||
serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
|
serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FflogsEncounterCatalogItem = {
|
||||||
|
displayName: string;
|
||||||
|
encounterId: number;
|
||||||
|
keys: string[];
|
||||||
|
zoneId?: number;
|
||||||
|
zoneName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotFflogsClientService {
|
export class QqbotFflogsClientService {
|
||||||
private accessToken = '';
|
private accessToken = '';
|
||||||
@ -36,6 +44,10 @@ export class QqbotFflogsClientService {
|
|||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly clientId: string;
|
private readonly clientId: string;
|
||||||
private readonly clientSecret: string;
|
private readonly clientSecret: string;
|
||||||
|
private encounterCatalogCache?: {
|
||||||
|
entries: FflogsEncounterCatalogItem[];
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
private readonly tokenUrl: string;
|
private readonly tokenUrl: string;
|
||||||
private readonly webBaseUrl: string;
|
private readonly webBaseUrl: string;
|
||||||
@ -44,12 +56,14 @@ export class QqbotFflogsClientService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly dictService: DictService,
|
private readonly dictService: DictService,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.normalizeBaseUrl(
|
|
||||||
this.configService.get<string>('FFLOGS_BASE_URL') ||
|
|
||||||
'https://www.fflogs.com',
|
|
||||||
);
|
|
||||||
this.webBaseUrl = this.normalizeBaseUrl(
|
this.webBaseUrl = this.normalizeBaseUrl(
|
||||||
this.configService.get<string>('FFLOGS_WEB_BASE_URL') ||
|
this.configService.get<string>('FFLOGS_WEB_BASE_URL') ||
|
||||||
|
this.configService.get<string>('FFLOGS_BASE_URL') ||
|
||||||
|
'https://cn.fflogs.com',
|
||||||
|
);
|
||||||
|
this.baseUrl = this.normalizeBaseUrl(
|
||||||
|
this.configService.get<string>('FFLOGS_BASE_URL') ||
|
||||||
|
this.webBaseUrl ||
|
||||||
'https://cn.fflogs.com',
|
'https://cn.fflogs.com',
|
||||||
);
|
);
|
||||||
this.graphqlUrl =
|
this.graphqlUrl =
|
||||||
@ -250,6 +264,7 @@ export class QqbotFflogsClientService {
|
|||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
|
zoneRankings(metric: dps)
|
||||||
recentReports(limit: $reportsLimit) {
|
recentReports(limit: $reportsLimit) {
|
||||||
data {
|
data {
|
||||||
code
|
code
|
||||||
@ -291,59 +306,223 @@ export class QqbotFflogsClientService {
|
|||||||
character.server?.slug || serverSlug,
|
character.server?.slug || serverSlug,
|
||||||
character.name || characterName,
|
character.name || characterName,
|
||||||
);
|
);
|
||||||
|
const replyUrl = this.buildCharacterEncounterUrl(
|
||||||
|
url,
|
||||||
|
encounterLookup,
|
||||||
|
params.partition,
|
||||||
|
);
|
||||||
const difficulty = this.toOptionalNumber(params.difficulty);
|
const difficulty = this.toOptionalNumber(params.difficulty);
|
||||||
|
const encounterNameById = this.buildRankingEncounterNameById(
|
||||||
|
character.zoneRankings,
|
||||||
|
);
|
||||||
const candidates = this.pickEncounterFightCandidates(
|
const candidates = this.pickEncounterFightCandidates(
|
||||||
character.recentReports?.data || [],
|
character.recentReports?.data || [],
|
||||||
encounterLookup,
|
encounterLookup,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
encounterNameById,
|
||||||
);
|
);
|
||||||
const metricCandidates = candidates.slice(0, Math.min(limit * 3, 30));
|
const metricCandidates = candidates.slice(0, Math.min(limit * 3, 30));
|
||||||
|
const encounterName = this.pickText(
|
||||||
|
encounterLookup.displayName,
|
||||||
|
candidates[0]?.fight?.name,
|
||||||
|
);
|
||||||
|
const encounterSuggestions = candidates.length
|
||||||
|
? []
|
||||||
|
: this.pickRecentEncounterSuggestions(
|
||||||
|
character.recentReports?.data || [],
|
||||||
|
encounterNameById,
|
||||||
|
);
|
||||||
|
const rankingSuggestions = candidates.length
|
||||||
|
? []
|
||||||
|
: this.pickRankingEncounterSuggestions(character.zoneRankings);
|
||||||
|
|
||||||
const logs = (
|
const rankingLogs =
|
||||||
await Promise.all(
|
encounterLookup.encounterId !== undefined
|
||||||
metricCandidates.map((candidate) =>
|
? await this.getEncounterRankingLogs({
|
||||||
this.getEncounterFightLog({
|
|
||||||
candidate,
|
|
||||||
characterId: character.id,
|
|
||||||
characterName: character.name || characterName,
|
characterName: character.name || characterName,
|
||||||
encounterLookup,
|
encounterLookup,
|
||||||
localizationMaps,
|
limit,
|
||||||
serverName,
|
partition: params.partition,
|
||||||
serverRegion,
|
serverRegion,
|
||||||
serverSlug: character.server?.slug || serverSlug,
|
serverSlug: character.server?.slug || serverSlug,
|
||||||
}),
|
timeframe: params.timeframe,
|
||||||
),
|
})
|
||||||
)
|
: [];
|
||||||
)
|
const fallbackLogs = rankingLogs.length
|
||||||
.filter(Boolean)
|
? []
|
||||||
.slice(0, limit);
|
: (
|
||||||
|
await Promise.all(
|
||||||
|
metricCandidates.map((candidate) =>
|
||||||
|
this.getEncounterFightLog({
|
||||||
|
candidate,
|
||||||
|
characterId: character.id,
|
||||||
|
characterName: character.name || characterName,
|
||||||
|
encounterNameById,
|
||||||
|
encounterLookup,
|
||||||
|
localizationMaps,
|
||||||
|
serverName,
|
||||||
|
serverRegion,
|
||||||
|
serverSlug: character.server?.slug || serverSlug,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, limit);
|
||||||
|
const logs = rankingLogs.length ? rankingLogs : fallbackLogs;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
characterName: character.name || characterName,
|
characterName: character.name || characterName,
|
||||||
encounterName: encounterLookup.displayName,
|
encounterName,
|
||||||
|
encounterSuggestions,
|
||||||
logs,
|
logs,
|
||||||
|
rankingSuggestions,
|
||||||
rankings: [],
|
rankings: [],
|
||||||
replyText: this.buildEncounterLogsReplyText({
|
replyText: this.buildEncounterLogsReplyText({
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
characterName: character.name || characterName,
|
characterName: character.name || characterName,
|
||||||
encounterName: encounterLookup.displayName,
|
encounterName,
|
||||||
|
encounterSuggestions,
|
||||||
logs,
|
logs,
|
||||||
localizationMaps,
|
localizationMaps,
|
||||||
|
rankingSuggestions,
|
||||||
serverName,
|
serverName,
|
||||||
serverRegion,
|
serverRegion,
|
||||||
url,
|
url: replyUrl,
|
||||||
}),
|
}),
|
||||||
serverName,
|
serverName,
|
||||||
serverRegion,
|
serverRegion,
|
||||||
url,
|
url: replyUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getEncounterRankingLogs(params: {
|
||||||
|
characterName: string;
|
||||||
|
encounterLookup: FflogsEncounterLookup;
|
||||||
|
limit: number;
|
||||||
|
partition?: number | string;
|
||||||
|
serverRegion: string;
|
||||||
|
serverSlug: string;
|
||||||
|
timeframe?: string;
|
||||||
|
}) {
|
||||||
|
if (params.encounterLookup.encounterId === undefined) return [];
|
||||||
|
const data =
|
||||||
|
await this.requestGraphql<FflogsCharacterEncounterRankingsResponse>(
|
||||||
|
`query QqbotFflogsCharacterEncounterRankings(
|
||||||
|
$characterName: String!
|
||||||
|
$serverSlug: String!
|
||||||
|
$serverRegion: String!
|
||||||
|
$encounterID: Int!
|
||||||
|
$partition: Int
|
||||||
|
$timeframe: RankingTimeframeType
|
||||||
|
) {
|
||||||
|
characterData {
|
||||||
|
character(
|
||||||
|
name: $characterName
|
||||||
|
serverSlug: $serverSlug
|
||||||
|
serverRegion: $serverRegion
|
||||||
|
) {
|
||||||
|
dpsRankings: encounterRankings(
|
||||||
|
encounterID: $encounterID
|
||||||
|
metric: dps
|
||||||
|
partition: $partition
|
||||||
|
timeframe: $timeframe
|
||||||
|
)
|
||||||
|
hpsRankings: encounterRankings(
|
||||||
|
encounterID: $encounterID
|
||||||
|
metric: hps
|
||||||
|
partition: $partition
|
||||||
|
timeframe: $timeframe
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
characterName: params.characterName,
|
||||||
|
encounterID: params.encounterLookup.encounterId,
|
||||||
|
partition: this.toOptionalNumber(params.partition),
|
||||||
|
serverRegion: params.serverRegion.toUpperCase(),
|
||||||
|
serverSlug: params.serverSlug,
|
||||||
|
timeframe: this.normalizeTimeframe(params.timeframe) || 'Historical',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const character = data.characterData?.character;
|
||||||
|
if (!character) return [];
|
||||||
|
const dpsPayload = this.normalizeJsonPayload(character.dpsRankings) as any;
|
||||||
|
const hpsPayload = this.normalizeJsonPayload(character.hpsRankings) as any;
|
||||||
|
const dpsRanks = Array.isArray(dpsPayload?.ranks) ? dpsPayload.ranks : [];
|
||||||
|
const hpsRanks = Array.isArray(hpsPayload?.ranks) ? hpsPayload.ranks : [];
|
||||||
|
const hpsByFight = new Map<string, any>();
|
||||||
|
for (const rank of hpsRanks) {
|
||||||
|
const key = this.buildRankingFightKey(rank);
|
||||||
|
if (key) hpsByFight.set(key, rank);
|
||||||
|
}
|
||||||
|
return dpsRanks
|
||||||
|
.map((rank) =>
|
||||||
|
this.buildEncounterRankingLogItem(
|
||||||
|
rank,
|
||||||
|
hpsByFight.get(this.buildRankingFightKey(rank)),
|
||||||
|
params.encounterLookup,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => Number(b.startTime || 0) - Number(a.startTime || 0))
|
||||||
|
.slice(0, params.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEncounterRankingLogItem(
|
||||||
|
damageRank: any,
|
||||||
|
healingRank: any,
|
||||||
|
encounterLookup: FflogsEncounterLookup,
|
||||||
|
): QqbotFflogsEncounterLogItem | null {
|
||||||
|
const code = `${damageRank?.report?.code || ''}`.trim();
|
||||||
|
const fightId = this.toOptionalNumber(damageRank?.report?.fightID);
|
||||||
|
if (!code || fightId === undefined) return null;
|
||||||
|
const damageScore = this.pickNumber(
|
||||||
|
damageRank?.rankPercent,
|
||||||
|
damageRank?.historicalPercent,
|
||||||
|
damageRank?.todayPercent,
|
||||||
|
);
|
||||||
|
const healingScore = this.pickNumber(
|
||||||
|
healingRank?.rankPercent,
|
||||||
|
healingRank?.historicalPercent,
|
||||||
|
healingRank?.todayPercent,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
adps: this.pickNumber(damageRank?.aDPS, damageRank?.cDPS),
|
||||||
|
color: this.getParseColor(damageScore),
|
||||||
|
damageScore,
|
||||||
|
dps: this.pickNumber(damageRank?.amount, damageRank?.pDPS),
|
||||||
|
durationMs: this.pickNumber(damageRank?.duration),
|
||||||
|
encounterName: encounterLookup.displayName,
|
||||||
|
fightId,
|
||||||
|
healingColor: this.getParseColor(healingScore),
|
||||||
|
healingScore,
|
||||||
|
hps: this.pickNumber(healingRank?.amount),
|
||||||
|
kill: true,
|
||||||
|
logCode: code,
|
||||||
|
logUrl: this.buildReportFightUrl(code, fightId),
|
||||||
|
ndps: this.pickNumber(damageRank?.nDPS),
|
||||||
|
rdps: this.pickNumber(damageRank?.rDPS),
|
||||||
|
startTime: this.pickNumber(
|
||||||
|
damageRank?.startTime,
|
||||||
|
damageRank?.report?.startTime,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRankingFightKey(rank: any) {
|
||||||
|
const code = `${rank?.report?.code || ''}`.trim();
|
||||||
|
const fightId = this.toOptionalNumber(rank?.report?.fightID);
|
||||||
|
return code && fightId !== undefined ? `${code}#${fightId}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
private async getEncounterFightLog(params: {
|
private async getEncounterFightLog(params: {
|
||||||
candidate: FflogsEncounterFightCandidate;
|
candidate: FflogsEncounterFightCandidate;
|
||||||
characterId?: number;
|
characterId?: number;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
|
encounterNameById: Map<number, string>;
|
||||||
encounterLookup: FflogsEncounterLookup;
|
encounterLookup: FflogsEncounterLookup;
|
||||||
localizationMaps: FflogsLocalizationMaps;
|
localizationMaps: FflogsLocalizationMaps;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@ -380,11 +559,13 @@ export class QqbotFflogsClientService {
|
|||||||
dataType: DamageDone
|
dataType: DamageDone
|
||||||
encounterID: $encounterID
|
encounterID: $encounterID
|
||||||
fightIDs: $fightIDs
|
fightIDs: $fightIDs
|
||||||
|
translate: true
|
||||||
)
|
)
|
||||||
healing: table(
|
healing: table(
|
||||||
dataType: Healing
|
dataType: Healing
|
||||||
encounterID: $encounterID
|
encounterID: $encounterID
|
||||||
fightIDs: $fightIDs
|
fightIDs: $fightIDs
|
||||||
|
translate: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -431,11 +612,11 @@ export class QqbotFflogsClientService {
|
|||||||
);
|
);
|
||||||
const encounterName = this.localizeEncounter(
|
const encounterName = this.localizeEncounter(
|
||||||
this.pickText(
|
this.pickText(
|
||||||
|
params.encounterNameById.get(encounterId),
|
||||||
candidate.fight.name,
|
candidate.fight.name,
|
||||||
params.encounterLookup.displayName,
|
params.encounterLookup.displayName,
|
||||||
`任务 ${candidate.fight.encounterID || ''}`,
|
`任务 ${candidate.fight.encounterID || ''}`,
|
||||||
),
|
),
|
||||||
params.localizationMaps,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -599,7 +780,13 @@ export class QqbotFflogsClientService {
|
|||||||
),
|
),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '公开排名:暂无公开排名数据';
|
: '公开排名:暂无公开排名数据';
|
||||||
return [header, idText, params.allStarText, rankingText, params.url]
|
return [
|
||||||
|
header,
|
||||||
|
idText,
|
||||||
|
params.allStarText,
|
||||||
|
rankingText,
|
||||||
|
this.formatDisplayUrl(params.url),
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
@ -608,8 +795,10 @@ export class QqbotFflogsClientService {
|
|||||||
characterId?: number;
|
characterId?: number;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
encounterName: string;
|
encounterName: string;
|
||||||
|
encounterSuggestions?: string[];
|
||||||
localizationMaps: FflogsLocalizationMaps;
|
localizationMaps: FflogsLocalizationMaps;
|
||||||
logs: QqbotFflogsEncounterLogItem[];
|
logs: QqbotFflogsEncounterLogItem[];
|
||||||
|
rankingSuggestions?: string[];
|
||||||
serverName: string;
|
serverName: string;
|
||||||
serverRegion: string;
|
serverRegion: string;
|
||||||
url: string;
|
url: string;
|
||||||
@ -618,19 +807,40 @@ export class QqbotFflogsClientService {
|
|||||||
params.serverRegion,
|
params.serverRegion,
|
||||||
params.localizationMaps,
|
params.localizationMaps,
|
||||||
);
|
);
|
||||||
const header = `FFLogs 最近记录:${params.characterName} @ ${params.serverName}(${region})`;
|
const header = `FFLogs 最近10次记录`;
|
||||||
const encounterText = `高难任务:${params.encounterName}`;
|
const characterText = `角色:${params.characterName} @ ${params.serverName}(${region})`;
|
||||||
|
const encounterText = `任务:${params.encounterName}`;
|
||||||
const idText = params.characterId ? `角色ID:${params.characterId}` : '';
|
const idText = params.characterId ? `角色ID:${params.characterId}` : '';
|
||||||
const logText = params.logs.length
|
const logText = params.logs.length
|
||||||
? [
|
? [
|
||||||
'最近10次:',
|
|
||||||
...params.logs.map((item, index) =>
|
...params.logs.map((item, index) =>
|
||||||
this.formatEncounterLogLine(item, index),
|
this.formatEncounterLogLine(item, index),
|
||||||
),
|
),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '最近10次:暂无匹配的公开记录';
|
: [
|
||||||
|
'暂无匹配的公开记录',
|
||||||
|
this.formatSuggestionLine(
|
||||||
|
'最近报告中可查',
|
||||||
|
params.encounterSuggestions,
|
||||||
|
),
|
||||||
|
this.formatSuggestionLine(
|
||||||
|
'公开排名中可查',
|
||||||
|
params.rankingSuggestions,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
return [header, encounterText, idText, logText, params.url]
|
return [
|
||||||
|
header,
|
||||||
|
characterText,
|
||||||
|
encounterText,
|
||||||
|
idText,
|
||||||
|
'',
|
||||||
|
logText,
|
||||||
|
'',
|
||||||
|
this.formatDisplayUrl(params.url),
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
@ -650,23 +860,25 @@ export class QqbotFflogsClientService {
|
|||||||
? `${this.formatNumber(item.healingScore)}`
|
? `${this.formatNumber(item.healingScore)}`
|
||||||
: '-';
|
: '-';
|
||||||
const metrics = [
|
const metrics = [
|
||||||
`DPS ${this.formatMetricNumber(item.dps)}`,
|
`D${this.formatMetricNumber(item.dps)}`,
|
||||||
`aDPS ${this.formatMetricNumber(item.adps)}`,
|
`aD${this.formatMetricNumber(item.adps)}`,
|
||||||
`rDPS ${this.formatMetricNumber(item.rdps)}`,
|
`rD${this.formatMetricNumber(item.rdps)}`,
|
||||||
`nDPS ${this.formatMetricNumber(item.ndps)}`,
|
`nD${this.formatMetricNumber(item.ndps)}`,
|
||||||
`HPS ${this.formatMetricNumber(item.hps)}`,
|
`H${this.formatMetricNumber(item.hps)}`,
|
||||||
].join(' / ');
|
].join('/');
|
||||||
return `${index + 1}. ${this.formatLogTime(
|
return [
|
||||||
item.startTime,
|
`${index + 1}. ${this.formatLogTime(item.startTime)}|${status}|${item.encounterName}`,
|
||||||
)}|${status}|颜色 ${item.color}|输出 ${damageScore}|治疗 ${
|
` 颜色:D${item.color}/H${item.healingColor}|评分:D${damageScore}/H${healingScore}`,
|
||||||
item.healingColor
|
` ${metrics}`,
|
||||||
} ${healingScore}|${metrics}|log ${item.logCode}#${item.fightId}`;
|
` ${this.formatDisplayUrl(item.logUrl)}`,
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickEncounterFightCandidates(
|
private pickEncounterFightCandidates(
|
||||||
reports: FflogsRecentReport[],
|
reports: FflogsRecentReport[],
|
||||||
encounterLookup: FflogsEncounterLookup,
|
encounterLookup: FflogsEncounterLookup,
|
||||||
difficulty?: number,
|
difficulty?: number,
|
||||||
|
encounterNameById = new Map<number, string>(),
|
||||||
) {
|
) {
|
||||||
return reports
|
return reports
|
||||||
.flatMap((report) =>
|
.flatMap((report) =>
|
||||||
@ -677,7 +889,9 @@ export class QqbotFflogsClientService {
|
|||||||
report,
|
report,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.filter(({ fight }) => this.matchEncounterFight(fight, encounterLookup))
|
.filter(({ fight }) =>
|
||||||
|
this.matchEncounterFight(fight, encounterLookup, encounterNameById),
|
||||||
|
)
|
||||||
.filter(
|
.filter(
|
||||||
({ fight }) =>
|
({ fight }) =>
|
||||||
difficulty === undefined || Number(fight.difficulty) === difficulty,
|
difficulty === undefined || Number(fight.difficulty) === difficulty,
|
||||||
@ -685,18 +899,81 @@ export class QqbotFflogsClientService {
|
|||||||
.sort((a, b) => b.absoluteStartTime - a.absoluteStartTime);
|
.sort((a, b) => b.absoluteStartTime - a.absoluteStartTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickRecentEncounterSuggestions(
|
||||||
|
reports: FflogsRecentReport[],
|
||||||
|
encounterNameById = new Map<number, string>(),
|
||||||
|
) {
|
||||||
|
const names = reports.flatMap((report) =>
|
||||||
|
(report.fights || []).map((fight) =>
|
||||||
|
this.pickText(
|
||||||
|
encounterNameById.get(this.toOptionalNumber(fight.encounterID) || 0),
|
||||||
|
fight.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.pickDistinctSuggestions(names, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRankingEncounterSuggestions(payload: unknown) {
|
||||||
|
const rankingsPayload = this.normalizeJsonPayload(payload) as any;
|
||||||
|
const rankings = this.pickRankings(rankingsPayload);
|
||||||
|
const names = rankings.map((item) =>
|
||||||
|
this.pickText(item.encounter?.name, item.encounterName, item.name),
|
||||||
|
);
|
||||||
|
return this.pickDistinctSuggestions(names, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRankingEncounterNameById(payload: unknown) {
|
||||||
|
const rankingsPayload = this.normalizeJsonPayload(payload) as any;
|
||||||
|
const rankings = this.pickRankings(rankingsPayload);
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const item of rankings) {
|
||||||
|
const id = this.pickNumber(item.encounter?.id, item.encounterID, item.id);
|
||||||
|
const name = this.pickText(
|
||||||
|
item.encounter?.name,
|
||||||
|
item.encounterName,
|
||||||
|
item.name,
|
||||||
|
);
|
||||||
|
if (id !== undefined && name) map.set(id, name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickDistinctSuggestions(values: any[], limit: number) {
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
const text = `${value || ''}`.trim();
|
||||||
|
if (!text || text.toLowerCase() === 'unknown') continue;
|
||||||
|
const key = this.normalizeLookupKey(text);
|
||||||
|
if (!key || keys.has(key)) continue;
|
||||||
|
keys.add(key);
|
||||||
|
suggestions.push(text);
|
||||||
|
if (suggestions.length >= limit) break;
|
||||||
|
}
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSuggestionLine(label: string, values?: string[]) {
|
||||||
|
const list = (values || []).filter(Boolean);
|
||||||
|
return list.length ? `${label}:${list.join('、')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
private matchEncounterFight(
|
private matchEncounterFight(
|
||||||
fight: FflogsReportFight,
|
fight: FflogsReportFight,
|
||||||
encounterLookup: FflogsEncounterLookup,
|
encounterLookup: FflogsEncounterLookup,
|
||||||
|
encounterNameById = new Map<number, string>(),
|
||||||
) {
|
) {
|
||||||
|
const encounterId = this.toOptionalNumber(fight.encounterID);
|
||||||
if (
|
if (
|
||||||
encounterLookup.encounterId !== undefined &&
|
encounterLookup.encounterId !== undefined &&
|
||||||
Number(fight.encounterID) === encounterLookup.encounterId
|
encounterId === encounterLookup.encounterId
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const fightKeys = this.buildLookupKeys(
|
const fightKeys = this.buildLookupKeys(
|
||||||
`${fight.name || ''}`,
|
`${fight.name || ''}`,
|
||||||
|
encounterId !== undefined ? encounterNameById.get(encounterId) || '' : '',
|
||||||
`${fight.encounterID || ''}`,
|
`${fight.encounterID || ''}`,
|
||||||
);
|
);
|
||||||
return encounterLookup.keys.some((key) => fightKeys.includes(key));
|
return encounterLookup.keys.some((key) => fightKeys.includes(key));
|
||||||
@ -707,47 +984,102 @@ export class QqbotFflogsClientService {
|
|||||||
): Promise<FflogsEncounterLookup> {
|
): Promise<FflogsEncounterLookup> {
|
||||||
const raw = `${input || ''}`.trim();
|
const raw = `${input || ''}`.trim();
|
||||||
const inputKeys = this.buildLookupKeys(raw);
|
const inputKeys = this.buildLookupKeys(raw);
|
||||||
const dicts = await this.dictService.getDictItemsByKey(
|
const catalog = await this.getFflogsEncounterCatalog();
|
||||||
FFLOGS_LOCALIZATION_DICT_CODES.encounter,
|
const matched = this.findEncounterCatalogMatch(inputKeys, catalog);
|
||||||
);
|
if (matched) {
|
||||||
const entries = dicts.map((item) => ({
|
|
||||||
displayName: `${item.label || item.value}`.trim(),
|
|
||||||
encounterId: this.toOptionalNumber(item.value),
|
|
||||||
keys: this.buildLookupKeys(`${item.label}`, `${item.value}`),
|
|
||||||
}));
|
|
||||||
const exact = entries.find((entry) =>
|
|
||||||
inputKeys.some((inputKey) => entry.keys.includes(inputKey)),
|
|
||||||
);
|
|
||||||
const prefix = exact
|
|
||||||
? undefined
|
|
||||||
: entries.find((entry) =>
|
|
||||||
inputKeys.some(
|
|
||||||
(inputKey) =>
|
|
||||||
inputKey.length >= 3 &&
|
|
||||||
entry.keys.some(
|
|
||||||
(key) => key.startsWith(inputKey) || key.includes(inputKey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const matched = exact || prefix;
|
|
||||||
|
|
||||||
if (!matched) {
|
|
||||||
return {
|
return {
|
||||||
displayName: raw,
|
displayName: matched.displayName,
|
||||||
encounterId: this.toOptionalNumber(raw),
|
encounterId: matched.encounterId,
|
||||||
input: raw,
|
input: raw,
|
||||||
keys: inputKeys,
|
keys: [...new Set([...inputKeys, ...matched.keys])],
|
||||||
|
zoneId: matched.zoneId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: matched.displayName,
|
displayName: raw,
|
||||||
encounterId: matched.encounterId,
|
encounterId: this.toOptionalNumber(raw),
|
||||||
input: raw,
|
input: raw,
|
||||||
keys: [...new Set([...matched.keys, ...inputKeys])],
|
keys: inputKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFflogsEncounterCatalog() {
|
||||||
|
if (
|
||||||
|
this.encounterCatalogCache &&
|
||||||
|
Date.now() < this.encounterCatalogCache.expiresAt
|
||||||
|
) {
|
||||||
|
return this.encounterCatalogCache.entries;
|
||||||
|
}
|
||||||
|
const data = await this.requestGraphql<{
|
||||||
|
worldData?: {
|
||||||
|
zones?: Array<{
|
||||||
|
encounters?: Array<{ id?: number; name?: string }>;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
`query QqbotFflogsEncounterCatalog {
|
||||||
|
worldData {
|
||||||
|
zones {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
encounters {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const entries = (data.worldData?.zones || []).flatMap((zone) =>
|
||||||
|
(zone.encounters || [])
|
||||||
|
.map((encounter) => {
|
||||||
|
const encounterId = this.toOptionalNumber(encounter.id);
|
||||||
|
const displayName = `${encounter.name || ''}`.trim();
|
||||||
|
if (encounterId === undefined || !displayName) return undefined;
|
||||||
|
return {
|
||||||
|
displayName,
|
||||||
|
encounterId,
|
||||||
|
keys: this.buildLookupKeys(
|
||||||
|
displayName,
|
||||||
|
`${encounterId}`,
|
||||||
|
`${zone.name || ''}`,
|
||||||
|
),
|
||||||
|
zoneId: this.toOptionalNumber(zone.id),
|
||||||
|
zoneName: zone.name,
|
||||||
|
} satisfies FflogsEncounterCatalogItem;
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
) as FflogsEncounterCatalogItem[];
|
||||||
|
this.encounterCatalogCache = {
|
||||||
|
entries,
|
||||||
|
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findEncounterCatalogMatch(
|
||||||
|
inputKeys: string[],
|
||||||
|
catalog: FflogsEncounterCatalogItem[],
|
||||||
|
) {
|
||||||
|
const exact = catalog.find((entry) =>
|
||||||
|
inputKeys.some((inputKey) => entry.keys.includes(inputKey)),
|
||||||
|
);
|
||||||
|
if (exact) return exact;
|
||||||
|
return catalog.find((entry) =>
|
||||||
|
inputKeys.some((inputKey) =>
|
||||||
|
entry.keys.some(
|
||||||
|
(key) =>
|
||||||
|
inputKey.length >= 2 &&
|
||||||
|
key.length >= 2 &&
|
||||||
|
(key.includes(inputKey) || inputKey.includes(key)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private findRankingCharacter(
|
private findRankingCharacter(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
target: {
|
target: {
|
||||||
@ -845,7 +1177,6 @@ export class QqbotFflogsClientService {
|
|||||||
item.name,
|
item.name,
|
||||||
`记录 ${index + 1}`,
|
`记录 ${index + 1}`,
|
||||||
),
|
),
|
||||||
localizationMaps,
|
|
||||||
);
|
);
|
||||||
const percent = this.pickNumber(
|
const percent = this.pickNumber(
|
||||||
item.rankPercent,
|
item.rankPercent,
|
||||||
@ -883,10 +1214,10 @@ export class QqbotFflogsClientService {
|
|||||||
const raw = Array.isArray(payload)
|
const raw = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
: Array.isArray(payload?.rankings)
|
: Array.isArray(payload?.rankings)
|
||||||
? payload.rankings
|
? payload.rankings
|
||||||
: Array.isArray(payload?.encounters)
|
: Array.isArray(payload?.encounters)
|
||||||
? payload.encounters
|
? payload.encounters
|
||||||
: [];
|
: [];
|
||||||
return raw
|
return raw
|
||||||
.filter((item) => item && typeof item === 'object')
|
.filter((item) => item && typeof item === 'object')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@ -925,6 +1256,14 @@ export class QqbotFflogsClientService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatDisplayUrl(value: string) {
|
||||||
|
try {
|
||||||
|
return decodeURI(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildCharacterUrl(
|
private buildCharacterUrl(
|
||||||
serverRegion: string,
|
serverRegion: string,
|
||||||
serverSlug: string,
|
serverSlug: string,
|
||||||
@ -935,6 +1274,22 @@ export class QqbotFflogsClientService {
|
|||||||
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`;
|
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCharacterEncounterUrl(
|
||||||
|
url: string,
|
||||||
|
encounterLookup: FflogsEncounterLookup,
|
||||||
|
partition?: number | string,
|
||||||
|
) {
|
||||||
|
if (encounterLookup.encounterId === undefined) return url;
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (encounterLookup.zoneId !== undefined) {
|
||||||
|
searchParams.set('zone', `${encounterLookup.zoneId}`);
|
||||||
|
}
|
||||||
|
searchParams.set('boss', `${encounterLookup.encounterId}`);
|
||||||
|
const partitionValue = this.toOptionalNumber(partition);
|
||||||
|
searchParams.set('partition', `${partitionValue ?? 0}`);
|
||||||
|
return `${url}?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildReportFightUrl(code: string, fightId: number) {
|
private buildReportFightUrl(code: string, fightId: number) {
|
||||||
return `${this.webBaseUrl}/reports/${encodeURIComponent(
|
return `${this.webBaseUrl}/reports/${encodeURIComponent(
|
||||||
code,
|
code,
|
||||||
@ -1083,8 +1438,7 @@ export class QqbotFflogsClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> {
|
private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> {
|
||||||
const [encounter, job, metric, role, serverRegion] = await Promise.all([
|
const [job, metric, role, serverRegion] = await Promise.all([
|
||||||
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.encounter),
|
|
||||||
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job),
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job),
|
||||||
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric),
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric),
|
||||||
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role),
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role),
|
||||||
@ -1092,7 +1446,6 @@ export class QqbotFflogsClientService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
|
||||||
job,
|
job,
|
||||||
metric,
|
metric,
|
||||||
role,
|
role,
|
||||||
@ -1111,13 +1464,8 @@ export class QqbotFflogsClientService {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private localizeEncounter(
|
private localizeEncounter(value: string) {
|
||||||
value: string,
|
return value;
|
||||||
localizationMaps: FflogsLocalizationMaps,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private localizeMetric(
|
private localizeMetric(
|
||||||
|
|||||||
@ -45,7 +45,8 @@ export class QqbotFflogsPluginService {
|
|||||||
properties: {
|
properties: {
|
||||||
characterName: { description: '角色名', type: 'string' },
|
characterName: { description: '角色名', type: 'string' },
|
||||||
encounter: {
|
encounter: {
|
||||||
description: '高难任务名,支持字典维护的中文名/英文名/缩写',
|
description:
|
||||||
|
'高难任务名,按 FFLogs 公开报告中的任务名或 encounterID 匹配',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
limit: {
|
limit: {
|
||||||
|
|||||||
@ -18,6 +18,17 @@ export type FflogsCharacterSummaryResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FflogsCharacterEncounterRankingsResponse = {
|
||||||
|
characterData?: {
|
||||||
|
character?:
|
||||||
|
| (FflogsCharacter & {
|
||||||
|
dpsRankings?: unknown;
|
||||||
|
hpsRankings?: unknown;
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type FflogsEncounterFightCandidate = {
|
export type FflogsEncounterFightCandidate = {
|
||||||
absoluteStartTime: number;
|
absoluteStartTime: number;
|
||||||
fight: FflogsReportFight;
|
fight: FflogsReportFight;
|
||||||
@ -29,6 +40,7 @@ export type FflogsEncounterLookup = {
|
|||||||
encounterId?: number;
|
encounterId?: number;
|
||||||
input: string;
|
input: string;
|
||||||
keys: string[];
|
keys: string[];
|
||||||
|
zoneId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FflogsGraphqlResponse<T> = {
|
export type FflogsGraphqlResponse<T> = {
|
||||||
@ -38,12 +50,7 @@ export type FflogsGraphqlResponse<T> = {
|
|||||||
|
|
||||||
export type FflogsHttpMethod = 'GET' | 'POST';
|
export type FflogsHttpMethod = 'GET' | 'POST';
|
||||||
|
|
||||||
export type FflogsLocalizationKey =
|
export type FflogsLocalizationKey = 'job' | 'metric' | 'role' | 'serverRegion';
|
||||||
| 'encounter'
|
|
||||||
| 'job'
|
|
||||||
| 'metric'
|
|
||||||
| 'role'
|
|
||||||
| 'serverRegion';
|
|
||||||
|
|
||||||
export type FflogsLocalizationMaps = Record<
|
export type FflogsLocalizationMaps = Record<
|
||||||
FflogsLocalizationKey,
|
FflogsLocalizationKey,
|
||||||
@ -124,7 +131,9 @@ export type QqbotFflogsCharacterSummaryResult = {
|
|||||||
characterId?: number;
|
characterId?: number;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
encounterName?: string;
|
encounterName?: string;
|
||||||
|
encounterSuggestions?: string[];
|
||||||
logs?: QqbotFflogsEncounterLogItem[];
|
logs?: QqbotFflogsEncounterLogItem[];
|
||||||
|
rankingSuggestions?: string[];
|
||||||
rankings: FflogsRankingItem[];
|
rankings: FflogsRankingItem[];
|
||||||
replyText: string;
|
replyText: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
|||||||
@ -35,15 +35,7 @@ describe('QqbotCommandParserService FFLogs parser', () => {
|
|||||||
} as QqbotCommand;
|
} as QqbotCommand;
|
||||||
|
|
||||||
const dictService = {
|
const dictService = {
|
||||||
getDictItemsByKey: jest.fn(async (dictKey: string) => {
|
getDictItemsByKey: jest.fn(async () => []),
|
||||||
if (dictKey === 'FFLOGS_ENCOUNTER_LABEL') {
|
|
||||||
return [
|
|
||||||
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
|
|
||||||
{ label: 'M10S Red Hot and Deep Blue', value: 'redhotanddeepblue' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
relationTree: jest.fn(async () => [
|
relationTree: jest.fn(async () => [
|
||||||
{
|
{
|
||||||
children: [
|
children: [
|
||||||
@ -72,27 +64,34 @@ describe('QqbotCommandParserService FFLogs parser', () => {
|
|||||||
|
|
||||||
const service = new QqbotCommandParserService(dictService);
|
const service = new QqbotCommandParserService(dictService);
|
||||||
|
|
||||||
it('parses Chinese encounter name after character and server', async () => {
|
beforeEach(() => {
|
||||||
const matched = await service.match(command, {
|
jest.clearAllMocks();
|
||||||
messageText: '/logs Anbbo 琥珀原 吸血鬼偶像',
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(matched?.input).toMatchObject({
|
|
||||||
characterName: 'Anbbo',
|
|
||||||
encounterName: '吸血鬼偶像',
|
|
||||||
serverSlug: '琥珀原',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses space separated encounter name when server is explicit', async () => {
|
it('parses Chinese encounter name after character and server without encounter dict', async () => {
|
||||||
const matched = await service.match(command, {
|
const matched = await service.match(command, {
|
||||||
messageText: '/logs Kwi 柊司 M10S Red Hot and Deep Blue server=琥珀原',
|
messageText: '/logs Kwi柊司 琥珀原 上位护锁刃龙',
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
expect(matched?.input).toMatchObject({
|
expect(matched?.input).toMatchObject({
|
||||||
characterName: 'Kwi 柊司',
|
characterName: 'Kwi柊司',
|
||||||
encounterName: 'M10S Red Hot and Deep Blue',
|
encounterName: '上位护锁刃龙',
|
||||||
serverSlug: '琥珀原',
|
serverSlug: '琥珀原',
|
||||||
});
|
});
|
||||||
|
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith(
|
||||||
|
'FFLOGS_ENCOUNTER_LABEL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps character summary parsing when no encounter is provided', async () => {
|
||||||
|
const matched = await service.match(command, {
|
||||||
|
messageText: '/logs Kwi柊司 琥珀原',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(matched?.input).toMatchObject({
|
||||||
|
characterName: 'Kwi柊司',
|
||||||
|
serverSlug: '琥珀原',
|
||||||
|
});
|
||||||
|
expect((matched?.input as any).encounterName).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -27,10 +27,6 @@ import { QqbotFflogsClientService } from '@/qqbot/plugins/fflogs/qqbot-fflogs-cl
|
|||||||
|
|
||||||
describe('QqbotFflogsClientService', () => {
|
describe('QqbotFflogsClientService', () => {
|
||||||
const dicts = {
|
const dicts = {
|
||||||
FFLOGS_ENCOUNTER_LABEL: [
|
|
||||||
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
|
|
||||||
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
|
|
||||||
],
|
|
||||||
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
|
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
|
||||||
FFLOGS_METRIC_LABEL: [
|
FFLOGS_METRIC_LABEL: [
|
||||||
{ label: 'DPS', value: 'dps' },
|
{ label: 'DPS', value: 'dps' },
|
||||||
@ -54,6 +50,13 @@ describe('QqbotFflogsClientService', () => {
|
|||||||
} as unknown as DictService,
|
} as unknown as DictService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('uses cn FFLogs API endpoints by default', () => {
|
||||||
|
expect((service as any).graphqlUrl).toBe(
|
||||||
|
'https://cn.fflogs.com/api/v2/client',
|
||||||
|
);
|
||||||
|
expect((service as any).tokenUrl).toBe('https://cn.fflogs.com/oauth/token');
|
||||||
|
});
|
||||||
|
|
||||||
it('formats character rankings with dict labels', async () => {
|
it('formats character rankings with dict labels', async () => {
|
||||||
const localizationMaps = await (service as any).getLocalizationMaps();
|
const localizationMaps = await (service as any).getLocalizationMaps();
|
||||||
const replyText = (service as any).buildReplyText({
|
const replyText = (service as any).buildReplyText({
|
||||||
@ -65,26 +68,26 @@ describe('QqbotFflogsClientService', () => {
|
|||||||
rankings: [
|
rankings: [
|
||||||
{
|
{
|
||||||
bestAmount: 36635,
|
bestAmount: 36635,
|
||||||
encounter: { name: 'Vamp Fatale' },
|
encounter: { name: '上位护锁刃龙' },
|
||||||
rankPercent: 72.4,
|
rankPercent: 72.4,
|
||||||
spec: 'Machinist',
|
spec: 'Machinist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bestAmount: 0,
|
bestAmount: 0,
|
||||||
encounter: { name: 'Lindwurm II' },
|
encounter: { name: '下位护锁刃龙' },
|
||||||
rankPercent: 0,
|
rankPercent: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
serverName: '琥珀原',
|
serverName: '琥珀原',
|
||||||
serverRegion: 'CN',
|
serverRegion: 'CN',
|
||||||
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
|
url: 'https://cn.fflogs.com/character/cn/%E7%90%A5%E7%8F%80%E5%8E%9F/Kwi%E6%9F%8A%E5%8F%B8?zone=67&boss=1082&partition=0',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replyText).toContain('FFLogs 战绩:Kwi柊司 @ 琥珀原(国服)');
|
expect(replyText).toContain('FFLogs 战绩:Kwi柊司 @ 琥珀原(国服)');
|
||||||
expect(replyText).toContain(
|
expect(replyText).toContain(
|
||||||
'1. M9S 吸血鬼偶像:72.4% | DPS 36,635 | 机工士',
|
'1. 上位护锁刃龙:72.4% | DPS 36,635 | 机工士',
|
||||||
);
|
);
|
||||||
expect(replyText).toContain('2. M12S P2 Lindwurm II:暂无有效排名');
|
expect(replyText).toContain('2. 下位护锁刃龙:暂无有效排名');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats recent encounter logs with Chinese encounter label and colors', async () => {
|
it('formats recent encounter logs with Chinese encounter label and colors', async () => {
|
||||||
@ -100,7 +103,7 @@ describe('QqbotFflogsClientService', () => {
|
|||||||
color: '蓝',
|
color: '蓝',
|
||||||
damageScore: 66.5,
|
damageScore: 66.5,
|
||||||
dps: 37560.7,
|
dps: 37560.7,
|
||||||
encounterName: 'M9S 吸血鬼偶像',
|
encounterName: '上位护锁刃龙',
|
||||||
fightId: 14,
|
fightId: 14,
|
||||||
healingColor: '灰',
|
healingColor: '灰',
|
||||||
healingScore: 0,
|
healingScore: 0,
|
||||||
@ -115,21 +118,37 @@ describe('QqbotFflogsClientService', () => {
|
|||||||
],
|
],
|
||||||
serverName: '琥珀原',
|
serverName: '琥珀原',
|
||||||
serverRegion: 'CN',
|
serverRegion: 'CN',
|
||||||
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
|
url: 'https://cn.fflogs.com/character/cn/%E7%90%A5%E7%8F%80%E5%8E%9F/Kwi%E6%9F%8A%E5%8F%B8?zone=67&boss=1082&partition=0',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replyText).toContain('高难任务:M9S 吸血鬼偶像');
|
expect(replyText).toContain('任务:M9S 吸血鬼偶像');
|
||||||
expect(replyText).toContain('颜色 蓝|输出 66.5|治疗 灰 0');
|
expect(replyText).toContain('1. 05/01 20:30|击杀|上位护锁刃龙');
|
||||||
|
expect(replyText).toContain('颜色:D蓝/H灰|评分:D66.5/H0');
|
||||||
|
expect(replyText).toContain('D37,561/aD37,561/rD36,635/nD36,635/H0');
|
||||||
expect(replyText).toContain(
|
expect(replyText).toContain(
|
||||||
'DPS 37,561 / aDPS 37,561 / rDPS 36,635 / nDPS 36,635 / HPS 0',
|
'https://cn.fflogs.com/reports/CgvFRqyxJhLtmND7#fight=14',
|
||||||
|
);
|
||||||
|
expect(replyText).toContain(
|
||||||
|
'https://cn.fflogs.com/character/cn/琥珀原/Kwi柊司?zone=67&boss=1082&partition=0',
|
||||||
);
|
);
|
||||||
expect(replyText).toContain('log CgvFRqyxJhLtmND7#14');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves Chinese encounter input from dict labels', async () => {
|
it('resolves Chinese encounter input from FFLogs encounter catalog', async () => {
|
||||||
const lookup = await (service as any).resolveEncounterLookup('吸血鬼偶像');
|
jest.spyOn(service as any, 'getFflogsEncounterCatalog').mockResolvedValue([
|
||||||
|
{
|
||||||
|
displayName: '护锁刃龙',
|
||||||
|
encounterId: 1082,
|
||||||
|
keys: ['护锁刃龙', '1082'],
|
||||||
|
zoneId: 67,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
expect(lookup.displayName).toBe('M9S 吸血鬼偶像');
|
const lookup = await (service as any).resolveEncounterLookup(
|
||||||
expect(lookup.keys).toContain('vampfatale');
|
'上位护锁刃龙',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lookup.displayName).toBe('护锁刃龙');
|
||||||
|
expect(lookup.encounterId).toBe(1082);
|
||||||
|
expect(lookup.zoneId).toBe(67);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user