diff --git a/.env.example b/.env.example index fd5c13e..9787b9b 100644 --- a/.env.example +++ b/.env.example @@ -84,10 +84,10 @@ FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2 FF14_DEFAULT_WORLD=中国 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_GRAPHQL_URL=https://www.fflogs.com/api/v2/client -FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token +FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client +FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token FFLOGS_CLIENT_ID= FFLOGS_CLIENT_SECRET= FFLOGS_DEFAULT_SERVER_REGION=CN diff --git a/dockerfile b/dockerfile index ae3656c..18ebaae 100644 --- a/dockerfile +++ b/dockerfile @@ -16,10 +16,10 @@ ENV LOKI_BATCH_INTERVAL_SECONDS=5 ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000 ENV LOKI_QUERY_MAX_LIMIT=1000 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_GRAPHQL_URL=https://www.fflogs.com/api/v2/client -ENV FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token +ENV FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client +ENV FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token ENV FFLOGS_DEFAULT_SERVER_REGION=CN ENV FFLOGS_REQUEST_TIMEOUT_MS=10000 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 272f64f..3cb86de 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -545,11 +545,6 @@ ON DUPLICATE KEY UPDATE INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`) 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), (2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1), (2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 1), diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index 59c1f06..50fd221 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -15,8 +15,6 @@ import { import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types'; import type { QqbotCommandMatchResult } from '../qqbot.types'; -const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL'; - @Injectable() export class QqbotCommandParserService { constructor(private readonly dictService: DictService) {} @@ -208,9 +206,8 @@ export class QqbotCommandParserService { } if (!encounterName && remainingPositionals.length > 2) { - const picked = await this.pickFflogsPositionalsByKnownWorld( - remainingPositionals, - ); + const picked = + await this.pickFflogsPositionalsByKnownWorld(remainingPositionals); if (picked) { characterName = characterName || picked.characterName; 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) { const joined = remainingPositionals.join(' '); if (joined.includes('@')) { @@ -350,44 +337,6 @@ export class QqbotCommandParserService { 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(); - for (const item of dicts) { - for (const key of this.buildFflogsLookupKeys(item.label, item.value)) { - keys.add(key); - } - } - return keys; - } - - private isFflogsEncounterName(catalog: Set, 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() { const treeCatalog = buildQqbotFf14MarketCatalogFromTree( await this.dictService.relationTree({ @@ -441,22 +390,4 @@ export class QqbotCommandParserService { if (value === true) return ''; 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, ''); - } } diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts index b97f7d3..73e11b7 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts @@ -4,6 +4,7 @@ import * as http from 'node:http'; import * as https from 'node:https'; import { DictService } from '../../../admin/dict/dict.service'; import type { + FflogsCharacterEncounterRankingsResponse, FflogsCharacterSummaryResponse, FflogsEncounterFightCandidate, FflogsEncounterLookup, @@ -22,13 +23,20 @@ import type { } from './qqbot-fflogs.types'; const FFLOGS_LOCALIZATION_DICT_CODES = { - encounter: 'FFLOGS_ENCOUNTER_LABEL', job: 'FFLOGS_JOB_LABEL', metric: 'FFLOGS_METRIC_LABEL', role: 'FFLOGS_ROLE_LABEL', serverRegion: 'FFLOGS_SERVER_REGION_LABEL', }; +type FflogsEncounterCatalogItem = { + displayName: string; + encounterId: number; + keys: string[]; + zoneId?: number; + zoneName?: string; +}; + @Injectable() export class QqbotFflogsClientService { private accessToken = ''; @@ -36,6 +44,10 @@ export class QqbotFflogsClientService { private readonly baseUrl: string; private readonly clientId: string; private readonly clientSecret: string; + private encounterCatalogCache?: { + entries: FflogsEncounterCatalogItem[]; + expiresAt: number; + }; private readonly graphqlUrl: string; private readonly tokenUrl: string; private readonly webBaseUrl: string; @@ -44,12 +56,14 @@ export class QqbotFflogsClientService { private readonly configService: ConfigService, private readonly dictService: DictService, ) { - this.baseUrl = this.normalizeBaseUrl( - this.configService.get('FFLOGS_BASE_URL') || - 'https://www.fflogs.com', - ); this.webBaseUrl = this.normalizeBaseUrl( this.configService.get('FFLOGS_WEB_BASE_URL') || + this.configService.get('FFLOGS_BASE_URL') || + 'https://cn.fflogs.com', + ); + this.baseUrl = this.normalizeBaseUrl( + this.configService.get('FFLOGS_BASE_URL') || + this.webBaseUrl || 'https://cn.fflogs.com', ); this.graphqlUrl = @@ -250,6 +264,7 @@ export class QqbotFflogsClientService { name slug } + zoneRankings(metric: dps) recentReports(limit: $reportsLimit) { data { code @@ -291,59 +306,223 @@ export class QqbotFflogsClientService { character.server?.slug || serverSlug, character.name || characterName, ); + const replyUrl = this.buildCharacterEncounterUrl( + url, + encounterLookup, + params.partition, + ); const difficulty = this.toOptionalNumber(params.difficulty); + const encounterNameById = this.buildRankingEncounterNameById( + character.zoneRankings, + ); const candidates = this.pickEncounterFightCandidates( character.recentReports?.data || [], encounterLookup, difficulty, + encounterNameById, ); 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 = ( - await Promise.all( - metricCandidates.map((candidate) => - this.getEncounterFightLog({ - candidate, - characterId: character.id, + const rankingLogs = + encounterLookup.encounterId !== undefined + ? await this.getEncounterRankingLogs({ characterName: character.name || characterName, encounterLookup, - localizationMaps, - serverName, + limit, + partition: params.partition, serverRegion, serverSlug: character.server?.slug || serverSlug, - }), - ), - ) - ) - .filter(Boolean) - .slice(0, limit); + timeframe: params.timeframe, + }) + : []; + const fallbackLogs = rankingLogs.length + ? [] + : ( + 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 { characterId: character.id, characterName: character.name || characterName, - encounterName: encounterLookup.displayName, + encounterName, + encounterSuggestions, logs, + rankingSuggestions, rankings: [], replyText: this.buildEncounterLogsReplyText({ characterId: character.id, characterName: character.name || characterName, - encounterName: encounterLookup.displayName, + encounterName, + encounterSuggestions, logs, localizationMaps, + rankingSuggestions, serverName, serverRegion, - url, + url: replyUrl, }), serverName, 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( + `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(); + 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: { candidate: FflogsEncounterFightCandidate; characterId?: number; characterName: string; + encounterNameById: Map; encounterLookup: FflogsEncounterLookup; localizationMaps: FflogsLocalizationMaps; serverName: string; @@ -380,11 +559,13 @@ export class QqbotFflogsClientService { dataType: DamageDone encounterID: $encounterID fightIDs: $fightIDs + translate: true ) healing: table( dataType: Healing encounterID: $encounterID fightIDs: $fightIDs + translate: true ) } } @@ -431,11 +612,11 @@ export class QqbotFflogsClientService { ); const encounterName = this.localizeEncounter( this.pickText( + params.encounterNameById.get(encounterId), candidate.fight.name, params.encounterLookup.displayName, `任务 ${candidate.fight.encounterID || ''}`, ), - params.localizationMaps, ); return { @@ -599,7 +780,13 @@ export class QqbotFflogsClientService { ), ].join('\n') : '公开排名:暂无公开排名数据'; - return [header, idText, params.allStarText, rankingText, params.url] + return [ + header, + idText, + params.allStarText, + rankingText, + this.formatDisplayUrl(params.url), + ] .filter(Boolean) .join('\n'); } @@ -608,8 +795,10 @@ export class QqbotFflogsClientService { characterId?: number; characterName: string; encounterName: string; + encounterSuggestions?: string[]; localizationMaps: FflogsLocalizationMaps; logs: QqbotFflogsEncounterLogItem[]; + rankingSuggestions?: string[]; serverName: string; serverRegion: string; url: string; @@ -618,19 +807,40 @@ export class QqbotFflogsClientService { params.serverRegion, params.localizationMaps, ); - const header = `FFLogs 最近记录:${params.characterName} @ ${params.serverName}(${region})`; - const encounterText = `高难任务:${params.encounterName}`; + const header = `FFLogs 最近10次记录`; + const characterText = `角色:${params.characterName} @ ${params.serverName}(${region})`; + const encounterText = `任务:${params.encounterName}`; const idText = params.characterId ? `角色ID:${params.characterId}` : ''; const logText = params.logs.length ? [ - '最近10次:', ...params.logs.map((item, index) => this.formatEncounterLogLine(item, index), ), ].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) .join('\n'); } @@ -650,23 +860,25 @@ export class QqbotFflogsClientService { ? `${this.formatNumber(item.healingScore)}` : '-'; const metrics = [ - `DPS ${this.formatMetricNumber(item.dps)}`, - `aDPS ${this.formatMetricNumber(item.adps)}`, - `rDPS ${this.formatMetricNumber(item.rdps)}`, - `nDPS ${this.formatMetricNumber(item.ndps)}`, - `HPS ${this.formatMetricNumber(item.hps)}`, - ].join(' / '); - return `${index + 1}. ${this.formatLogTime( - item.startTime, - )}|${status}|颜色 ${item.color}|输出 ${damageScore}|治疗 ${ - item.healingColor - } ${healingScore}|${metrics}|log ${item.logCode}#${item.fightId}`; + `D${this.formatMetricNumber(item.dps)}`, + `aD${this.formatMetricNumber(item.adps)}`, + `rD${this.formatMetricNumber(item.rdps)}`, + `nD${this.formatMetricNumber(item.ndps)}`, + `H${this.formatMetricNumber(item.hps)}`, + ].join('/'); + return [ + `${index + 1}. ${this.formatLogTime(item.startTime)}|${status}|${item.encounterName}`, + ` 颜色:D${item.color}/H${item.healingColor}|评分:D${damageScore}/H${healingScore}`, + ` ${metrics}`, + ` ${this.formatDisplayUrl(item.logUrl)}`, + ].join('\n'); } private pickEncounterFightCandidates( reports: FflogsRecentReport[], encounterLookup: FflogsEncounterLookup, difficulty?: number, + encounterNameById = new Map(), ) { return reports .flatMap((report) => @@ -677,7 +889,9 @@ export class QqbotFflogsClientService { report, })), ) - .filter(({ fight }) => this.matchEncounterFight(fight, encounterLookup)) + .filter(({ fight }) => + this.matchEncounterFight(fight, encounterLookup, encounterNameById), + ) .filter( ({ fight }) => difficulty === undefined || Number(fight.difficulty) === difficulty, @@ -685,18 +899,81 @@ export class QqbotFflogsClientService { .sort((a, b) => b.absoluteStartTime - a.absoluteStartTime); } + private pickRecentEncounterSuggestions( + reports: FflogsRecentReport[], + encounterNameById = new Map(), + ) { + 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(); + 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(); + 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( fight: FflogsReportFight, encounterLookup: FflogsEncounterLookup, + encounterNameById = new Map(), ) { + const encounterId = this.toOptionalNumber(fight.encounterID); if ( encounterLookup.encounterId !== undefined && - Number(fight.encounterID) === encounterLookup.encounterId + encounterId === encounterLookup.encounterId ) { return true; } const fightKeys = this.buildLookupKeys( `${fight.name || ''}`, + encounterId !== undefined ? encounterNameById.get(encounterId) || '' : '', `${fight.encounterID || ''}`, ); return encounterLookup.keys.some((key) => fightKeys.includes(key)); @@ -707,47 +984,102 @@ export class QqbotFflogsClientService { ): Promise { const raw = `${input || ''}`.trim(); const inputKeys = this.buildLookupKeys(raw); - const dicts = await this.dictService.getDictItemsByKey( - FFLOGS_LOCALIZATION_DICT_CODES.encounter, - ); - 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) { + const catalog = await this.getFflogsEncounterCatalog(); + const matched = this.findEncounterCatalogMatch(inputKeys, catalog); + if (matched) { return { - displayName: raw, - encounterId: this.toOptionalNumber(raw), + displayName: matched.displayName, + encounterId: matched.encounterId, input: raw, - keys: inputKeys, + keys: [...new Set([...inputKeys, ...matched.keys])], + zoneId: matched.zoneId, }; } - return { - displayName: matched.displayName, - encounterId: matched.encounterId, + displayName: raw, + encounterId: this.toOptionalNumber(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( payload: unknown, target: { @@ -845,7 +1177,6 @@ export class QqbotFflogsClientService { item.name, `记录 ${index + 1}`, ), - localizationMaps, ); const percent = this.pickNumber( item.rankPercent, @@ -883,10 +1214,10 @@ export class QqbotFflogsClientService { const raw = Array.isArray(payload) ? payload : Array.isArray(payload?.rankings) - ? payload.rankings - : Array.isArray(payload?.encounters) - ? payload.encounters - : []; + ? payload.rankings + : Array.isArray(payload?.encounters) + ? payload.encounters + : []; return raw .filter((item) => item && typeof item === 'object') .sort((a, b) => { @@ -925,6 +1256,14 @@ export class QqbotFflogsClientService { return value; } + private formatDisplayUrl(value: string) { + try { + return decodeURI(value); + } catch { + return value; + } + } + private buildCharacterUrl( serverRegion: string, serverSlug: string, @@ -935,6 +1274,22 @@ export class QqbotFflogsClientService { )}/${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) { return `${this.webBaseUrl}/reports/${encodeURIComponent( code, @@ -1083,8 +1438,7 @@ export class QqbotFflogsClientService { } private async getLocalizationMaps(): Promise { - const [encounter, job, metric, role, serverRegion] = await Promise.all([ - this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.encounter), + const [job, metric, role, serverRegion] = await Promise.all([ this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job), this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric), this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role), @@ -1092,7 +1446,6 @@ export class QqbotFflogsClientService { ]); return { - encounter, job, metric, role, @@ -1111,13 +1464,8 @@ export class QqbotFflogsClientService { return map; } - private localizeEncounter( - value: string, - localizationMaps: FflogsLocalizationMaps, - ) { - return ( - localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value - ); + private localizeEncounter(value: string) { + return value; } private localizeMetric( diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts index ddd19c7..5192520 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts @@ -45,7 +45,8 @@ export class QqbotFflogsPluginService { properties: { characterName: { description: '角色名', type: 'string' }, encounter: { - description: '高难任务名,支持字典维护的中文名/英文名/缩写', + description: + '高难任务名,按 FFLogs 公开报告中的任务名或 encounterID 匹配', type: 'string', }, limit: { diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts index 8491165..3e05e83 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts @@ -18,6 +18,17 @@ export type FflogsCharacterSummaryResponse = { }; }; +export type FflogsCharacterEncounterRankingsResponse = { + characterData?: { + character?: + | (FflogsCharacter & { + dpsRankings?: unknown; + hpsRankings?: unknown; + }) + | null; + }; +}; + export type FflogsEncounterFightCandidate = { absoluteStartTime: number; fight: FflogsReportFight; @@ -29,6 +40,7 @@ export type FflogsEncounterLookup = { encounterId?: number; input: string; keys: string[]; + zoneId?: number; }; export type FflogsGraphqlResponse = { @@ -38,12 +50,7 @@ export type FflogsGraphqlResponse = { export type FflogsHttpMethod = 'GET' | 'POST'; -export type FflogsLocalizationKey = - | 'encounter' - | 'job' - | 'metric' - | 'role' - | 'serverRegion'; +export type FflogsLocalizationKey = 'job' | 'metric' | 'role' | 'serverRegion'; export type FflogsLocalizationMaps = Record< FflogsLocalizationKey, @@ -124,7 +131,9 @@ export type QqbotFflogsCharacterSummaryResult = { characterId?: number; characterName: string; encounterName?: string; + encounterSuggestions?: string[]; logs?: QqbotFflogsEncounterLogItem[]; + rankingSuggestions?: string[]; rankings: FflogsRankingItem[]; replyText: string; serverName: string; diff --git a/test/qqbot/command/qqbot-command-parser.service.spec.ts b/test/qqbot/command/qqbot-command-parser.service.spec.ts index 1e624a3..a29b445 100644 --- a/test/qqbot/command/qqbot-command-parser.service.spec.ts +++ b/test/qqbot/command/qqbot-command-parser.service.spec.ts @@ -35,15 +35,7 @@ describe('QqbotCommandParserService FFLogs parser', () => { } as QqbotCommand; const dictService = { - getDictItemsByKey: jest.fn(async (dictKey: string) => { - if (dictKey === 'FFLOGS_ENCOUNTER_LABEL') { - return [ - { label: 'M9S 吸血鬼偶像', value: 'vampfatale' }, - { label: 'M10S Red Hot and Deep Blue', value: 'redhotanddeepblue' }, - ]; - } - return []; - }), + getDictItemsByKey: jest.fn(async () => []), relationTree: jest.fn(async () => [ { children: [ @@ -72,27 +64,34 @@ describe('QqbotCommandParserService FFLogs parser', () => { const service = new QqbotCommandParserService(dictService); - it('parses Chinese encounter name after character and server', async () => { - const matched = await service.match(command, { - messageText: '/logs Anbbo 琥珀原 吸血鬼偶像', - } as any); - - expect(matched?.input).toMatchObject({ - characterName: 'Anbbo', - encounterName: '吸血鬼偶像', - serverSlug: '琥珀原', - }); + beforeEach(() => { + jest.clearAllMocks(); }); - 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, { - messageText: '/logs Kwi 柊司 M10S Red Hot and Deep Blue server=琥珀原', + messageText: '/logs Kwi柊司 琥珀原 上位护锁刃龙', } as any); expect(matched?.input).toMatchObject({ - characterName: 'Kwi 柊司', - encounterName: 'M10S Red Hot and Deep Blue', + characterName: 'Kwi柊司', + encounterName: '上位护锁刃龙', 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(''); }); }); diff --git a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts index 05e8d5c..c1acdf3 100644 --- a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts +++ b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts @@ -27,10 +27,6 @@ import { QqbotFflogsClientService } from '@/qqbot/plugins/fflogs/qqbot-fflogs-cl describe('QqbotFflogsClientService', () => { const dicts = { - FFLOGS_ENCOUNTER_LABEL: [ - { label: 'M9S 吸血鬼偶像', value: 'vampfatale' }, - { label: 'M12S P2 Lindwurm II', value: 'lindwurmii' }, - ], FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }], FFLOGS_METRIC_LABEL: [ { label: 'DPS', value: 'dps' }, @@ -54,6 +50,13 @@ describe('QqbotFflogsClientService', () => { } 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 () => { const localizationMaps = await (service as any).getLocalizationMaps(); const replyText = (service as any).buildReplyText({ @@ -65,26 +68,26 @@ describe('QqbotFflogsClientService', () => { rankings: [ { bestAmount: 36635, - encounter: { name: 'Vamp Fatale' }, + encounter: { name: '上位护锁刃龙' }, rankPercent: 72.4, spec: 'Machinist', }, { bestAmount: 0, - encounter: { name: 'Lindwurm II' }, + encounter: { name: '下位护锁刃龙' }, rankPercent: 0, }, ], serverName: '琥珀原', 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( - '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 () => { @@ -100,7 +103,7 @@ describe('QqbotFflogsClientService', () => { color: '蓝', damageScore: 66.5, dps: 37560.7, - encounterName: 'M9S 吸血鬼偶像', + encounterName: '上位护锁刃龙', fightId: 14, healingColor: '灰', healingScore: 0, @@ -115,21 +118,37 @@ describe('QqbotFflogsClientService', () => { ], serverName: '琥珀原', 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('颜色 蓝|输出 66.5|治疗 灰 0'); + expect(replyText).toContain('任务:M9S 吸血鬼偶像'); + 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( - '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 () => { - const lookup = await (service as any).resolveEncounterLookup('吸血鬼偶像'); + it('resolves Chinese encounter input from FFLogs encounter catalog', async () => { + jest.spyOn(service as any, 'getFflogsEncounterCatalog').mockResolvedValue([ + { + displayName: '护锁刃龙', + encounterId: 1082, + keys: ['护锁刃龙', '1082'], + zoneId: 67, + }, + ]); - expect(lookup.displayName).toBe('M9S 吸血鬼偶像'); - expect(lookup.keys).toContain('vampfatale'); + const lookup = await (service as any).resolveEncounterLookup( + '上位护锁刃龙', + ); + + expect(lookup.displayName).toBe('护锁刃龙'); + expect(lookup.encounterId).toBe(1082); + expect(lookup.zoneId).toBe(67); }); });