@bbki.ng/backend 0.3.21 → 0.4.0

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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * COC Wiki / Development Guide Service
3
+ *
4
+ * Architecture:
5
+ * - Strategy templates and game stats are sourced externally (GitHub).
6
+ * - Cloudflare KV acts as a fast local cache.
7
+ * - CocWikiDataImporter handles fetching and validation.
8
+ */
9
+
10
+ import {
11
+ cocWikiDataImporter,
12
+ type RefinedGameData,
13
+ type WikiGuide,
14
+ } from './cocWikiDataImporter.service';
15
+
16
+ export interface DevelopmentGuideResult {
17
+ topic: string;
18
+ townHallLevel?: number;
19
+ advice: string;
20
+ }
21
+
22
+ const GENERIC_ADVICE =
23
+ '这是一个很好的问题。作为 COC 顾问,我建议你:\n' +
24
+ '1. 明确当前大本等级和目标\n' +
25
+ '2. 优先升级进攻类建筑(实验室、兵营、训练营)\n' +
26
+ '3. 保持英雄持续升级\n' +
27
+ '4. 多观察高本玩家的阵型和打法\n' +
28
+ '5. 积极参与部落战和部落竞赛获取奖励\n\n' +
29
+ '你可以告诉我更具体的需求,比如兵种搭配、防御布局、资源管理等。';
30
+
31
+ const DATA_UNAVAILABLE_ADVICE =
32
+ '数据暂时不可用,正在从外部数据源同步最新信息。请稍后再试,或询问其他问题。';
33
+
34
+ export class CocWikiService {
35
+ /**
36
+ * Query development guide by topic and optional town hall level.
37
+ * Safe for null/undefined topic (fixes previous toLowerCase NPE).
38
+ */
39
+ async query(
40
+ kv: KVNamespace,
41
+ topic: string | undefined | null,
42
+ townHallLevel?: number
43
+ ): Promise<DevelopmentGuideResult> {
44
+ // Fix: guard against null/undefined topic
45
+ const safeTopic = topic?.toLowerCase?.() ?? '';
46
+
47
+ if (!safeTopic) {
48
+ return {
49
+ topic: topic || '',
50
+ townHallLevel,
51
+ advice: GENERIC_ADVICE,
52
+ };
53
+ }
54
+
55
+ // Load guide from KV (or fetch externally if cache miss)
56
+ const guide = await cocWikiDataImporter.getGuideFromKV(kv);
57
+
58
+ if (!guide) {
59
+ return {
60
+ topic: topic || '',
61
+ townHallLevel,
62
+ advice: DATA_UNAVAILABLE_ADVICE,
63
+ };
64
+ }
65
+
66
+ // Match topic against guide keywords
67
+ const matchedKey = this.findMatchingTopic(guide, safeTopic);
68
+
69
+ if (matchedKey) {
70
+ const topicData = guide.topics[matchedKey];
71
+ let advice = topicData.template;
72
+
73
+ // Enrich with live game data if town hall level is provided
74
+ if (townHallLevel) {
75
+ const gameData = await cocWikiDataImporter.getGameDataFromKV(kv);
76
+ advice = this.enrichAdvice(advice, townHallLevel, gameData);
77
+ }
78
+
79
+ return {
80
+ topic: matchedKey,
81
+ townHallLevel,
82
+ advice,
83
+ };
84
+ }
85
+
86
+ return {
87
+ topic: topic || '',
88
+ townHallLevel,
89
+ advice: GENERIC_ADVICE,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Find the best matching topic key from the guide.
95
+ */
96
+ private findMatchingTopic(guide: WikiGuide, lowerTopic: string): string | null {
97
+ for (const [key, topicData] of Object.entries(guide.topics)) {
98
+ const lowerKey = key.toLowerCase();
99
+ // Check main key match
100
+ if (lowerTopic.includes(lowerKey) || lowerKey.includes(lowerTopic)) {
101
+ return key;
102
+ }
103
+ // Check keyword list match
104
+ if (topicData.keywords) {
105
+ for (const kw of topicData.keywords) {
106
+ const lowerKw = kw.toLowerCase();
107
+ if (lowerTopic.includes(lowerKw) || lowerKw.includes(lowerTopic)) {
108
+ return key;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Enrich strategy template with actual game statistics.
118
+ */
119
+ private enrichAdvice(
120
+ template: string,
121
+ thLevel: number,
122
+ gameData: RefinedGameData | null
123
+ ): string {
124
+ if (!gameData) return template;
125
+
126
+ const lines: string[] = [template];
127
+ lines.push('');
128
+ lines.push(`(针对 ${thLevel} 本玩家的数据补充:)`);
129
+
130
+ // Town Hall info
131
+ const thBuilding = gameData.buildings.find(b => b.name === 'Town Hall');
132
+ const thMaxLevel = thBuilding?.levels?.length || 0;
133
+ if (thLevel <= thMaxLevel) {
134
+ lines.push(`- 当前大本最高等级为 ${thMaxLevel} 级`);
135
+ }
136
+
137
+ // Buildings available at this TH
138
+ const availableBuildings = gameData.buildings.filter(b => {
139
+ const minTh = b.levels[0]?.required_townhall ?? 999;
140
+ return b.name !== 'Town Hall' && minTh <= thLevel;
141
+ });
142
+ if (availableBuildings.length > 0) {
143
+ lines.push(`- 当前大本已解锁 ${availableBuildings.length} 种建筑`);
144
+ }
145
+
146
+ // Heroes available at this TH
147
+ const availableHeroes = gameData.heroes.filter(h => {
148
+ const minTh = h.levels[0]?.required_townhall ?? 999;
149
+ return minTh <= thLevel;
150
+ });
151
+ if (availableHeroes.length > 0) {
152
+ const heroNames = availableHeroes.map(h => h.name).join('、');
153
+ lines.push(`- 已解锁英雄:${heroNames}`);
154
+ }
155
+
156
+ // Troops available at this TH
157
+ const availableTroops = gameData.troops.filter(t => {
158
+ const minTh = t.levels[0]?.required_townhall ?? 999;
159
+ return t.village === 'home' && minTh <= thLevel;
160
+ });
161
+ if (availableTroops.length > 0) {
162
+ lines.push(`- 已解锁家乡兵种:${availableTroops.length} 种`);
163
+ }
164
+
165
+ lines.push('');
166
+ lines.push('数据来源:coc.py 社区维护数据集');
167
+
168
+ return lines.join('\n');
169
+ }
170
+ }
171
+
172
+ export const cocWikiService = new CocWikiService();
@@ -0,0 +1,261 @@
1
+ /**
2
+ * COC Wiki Data Importer
3
+ *
4
+ * Fetches external community-maintained data sources and caches them
5
+ * in Cloudflare KV for fast access by CocWikiService.
6
+ *
7
+ * Data Sources:
8
+ * - coc.py static_data.json (GitHub): Building, troop, hero, spell stats
9
+ * - Custom guide JSON (GitHub Raw): Strategy templates and advice
10
+ */
11
+
12
+ const COC_PY_STATIC_DATA_URL =
13
+ 'https://raw.githubusercontent.com/mathsman5133/coc.py/master/coc/static/static_data.json';
14
+
15
+ const DEFAULT_GUIDE_URL =
16
+ 'https://raw.githubusercontent.com/bbbottle/bottle/main/apps/backend/data/coc-wiki-guide.json';
17
+
18
+ const KV_KEY_GUIDE = 'coc-wiki:guide:v1';
19
+ const KV_KEY_GAME_DATA = 'coc-wiki:game-data:v1';
20
+ const KV_KEY_LAST_SYNC = 'coc-wiki:last-sync';
21
+ const DEFAULT_CACHE_TTL_SECONDS = 86400 * 7; // 7 days
22
+
23
+ export interface GameUnit {
24
+ name: string;
25
+ type: string;
26
+ village: string;
27
+ levels: Array<{
28
+ level: number;
29
+ build_cost?: number;
30
+ build_time?: number;
31
+ required_townhall?: number;
32
+ hitpoints?: number;
33
+ dps?: number;
34
+ housing_space?: number;
35
+ training_time?: number;
36
+ }>;
37
+ }
38
+
39
+ export interface RefinedGameData {
40
+ buildings: GameUnit[];
41
+ troops: GameUnit[];
42
+ heroes: GameUnit[];
43
+ spells: GameUnit[];
44
+ traps: GameUnit[];
45
+ lastUpdated: string;
46
+ }
47
+
48
+ export interface GuideTopic {
49
+ keywords: string[];
50
+ template: string;
51
+ }
52
+
53
+ export interface WikiGuide {
54
+ _meta: {
55
+ source: string;
56
+ version: string;
57
+ lastUpdated: string;
58
+ };
59
+ topics: Record<string, GuideTopic>;
60
+ }
61
+
62
+ export interface SyncResult {
63
+ success: boolean;
64
+ guideLoaded: boolean;
65
+ gameDataLoaded: boolean;
66
+ guideTopicsCount: number;
67
+ gameDataUnitsCount: number;
68
+ errors: string[];
69
+ timestamp: string;
70
+ }
71
+
72
+ export class CocWikiDataImporter {
73
+ private readonly guideUrl: string;
74
+
75
+ constructor(guideUrl?: string) {
76
+ this.guideUrl = guideUrl || DEFAULT_GUIDE_URL;
77
+ }
78
+
79
+ /**
80
+ * Run full sync: fetch external sources and write to KV.
81
+ */
82
+ async sync(kv: KVNamespace): Promise<SyncResult> {
83
+ const errors: string[] = [];
84
+ let guideLoaded = false;
85
+ let gameDataLoaded = false;
86
+ let guideTopicsCount = 0;
87
+ let gameDataUnitsCount = 0;
88
+
89
+ // 1. Fetch and cache guide
90
+ try {
91
+ const guide = await this.fetchGuide();
92
+ guideTopicsCount = Object.keys(guide.topics).length;
93
+ await kv.put(KV_KEY_GUIDE, JSON.stringify(guide), {
94
+ expirationTtl: DEFAULT_CACHE_TTL_SECONDS,
95
+ });
96
+ guideLoaded = true;
97
+ } catch (err) {
98
+ errors.push(`Guide fetch failed: ${err instanceof Error ? err.message : String(err)}`);
99
+ }
100
+
101
+ // 2. Fetch and cache game data
102
+ try {
103
+ const gameData = await this.fetchAndRefineGameData();
104
+ gameDataUnitsCount =
105
+ gameData.buildings.length +
106
+ gameData.troops.length +
107
+ gameData.heroes.length +
108
+ gameData.spells.length +
109
+ gameData.traps.length;
110
+ await kv.put(KV_KEY_GAME_DATA, JSON.stringify(gameData), {
111
+ expirationTtl: DEFAULT_CACHE_TTL_SECONDS,
112
+ });
113
+ gameDataLoaded = true;
114
+ } catch (err) {
115
+ errors.push(`Game data fetch failed: ${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+
118
+ // 3. Record sync time
119
+ const timestamp = new Date().toISOString();
120
+ await kv.put(KV_KEY_LAST_SYNC, timestamp);
121
+
122
+ return {
123
+ success: guideLoaded || gameDataLoaded,
124
+ guideLoaded,
125
+ gameDataLoaded,
126
+ guideTopicsCount,
127
+ gameDataUnitsCount,
128
+ errors,
129
+ timestamp,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Fetch strategy guide from external URL.
135
+ */
136
+ async fetchGuide(): Promise<WikiGuide> {
137
+ const response = await fetch(this.guideUrl, {
138
+ headers: { Accept: 'application/json' },
139
+ });
140
+
141
+ if (!response.ok) {
142
+ throw new Error(`Guide HTTP ${response.status}: ${response.statusText}`);
143
+ }
144
+
145
+ const data = (await response.json()) as WikiGuide;
146
+
147
+ if (!data.topics || typeof data.topics !== 'object') {
148
+ throw new Error('Invalid guide format: missing topics object');
149
+ }
150
+
151
+ return data;
152
+ }
153
+
154
+ /**
155
+ * Fetch coc.py static_data.json and extract relevant units.
156
+ */
157
+ async fetchAndRefineGameData(): Promise<RefinedGameData> {
158
+ const response = await fetch(COC_PY_STATIC_DATA_URL, {
159
+ headers: { Accept: 'application/json' },
160
+ });
161
+
162
+ if (!response.ok) {
163
+ throw new Error(`Game data HTTP ${response.status}: ${response.statusText}`);
164
+ }
165
+
166
+ interface RawLevel {
167
+ level?: number;
168
+ build_cost?: number;
169
+ build_time?: number;
170
+ required_townhall?: number;
171
+ hitpoints?: number;
172
+ dps?: number;
173
+ housing_space?: number;
174
+ training_time?: number;
175
+ }
176
+
177
+ interface RawUnit {
178
+ name?: string;
179
+ type?: string;
180
+ village?: string;
181
+ levels?: RawLevel[];
182
+ }
183
+
184
+ const raw = (await response.json()) as Record<string, RawUnit[]>;
185
+
186
+ const pickFields = (arr: RawUnit[]): GameUnit[] =>
187
+ (arr || [])
188
+ .filter(item => item && typeof item === 'object')
189
+ .map(item => ({
190
+ name: String(item.name || ''),
191
+ type: String(item.type || ''),
192
+ village: String(item.village || 'home'),
193
+ levels: (item.levels || []).map((lv: RawLevel) => ({
194
+ level: Number(lv.level || 0),
195
+ build_cost: lv.build_cost != null ? Number(lv.build_cost) : undefined,
196
+ build_time: lv.build_time != null ? Number(lv.build_time) : undefined,
197
+ required_townhall:
198
+ lv.required_townhall != null ? Number(lv.required_townhall) : undefined,
199
+ hitpoints: lv.hitpoints != null ? Number(lv.hitpoints) : undefined,
200
+ dps: lv.dps != null ? Number(lv.dps) : undefined,
201
+ housing_space: lv.housing_space != null ? Number(lv.housing_space) : undefined,
202
+ training_time: lv.training_time != null ? Number(lv.training_time) : undefined,
203
+ })),
204
+ }))
205
+ .filter(u => u.name);
206
+
207
+ return {
208
+ buildings: pickFields(raw.buildings),
209
+ troops: pickFields(raw.troops),
210
+ heroes: pickFields(raw.heroes),
211
+ spells: pickFields(raw.spells),
212
+ traps: pickFields(raw.traps),
213
+ lastUpdated: new Date().toISOString(),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Get cached guide from KV, or fetch fresh if missing.
219
+ */
220
+ async getGuideFromKV(kv: KVNamespace): Promise<WikiGuide | null> {
221
+ const cached = await kv.get<WikiGuide>(KV_KEY_GUIDE, 'json');
222
+ if (cached) return cached;
223
+
224
+ try {
225
+ const fresh = await this.fetchGuide();
226
+ await kv.put(KV_KEY_GUIDE, JSON.stringify(fresh), {
227
+ expirationTtl: DEFAULT_CACHE_TTL_SECONDS,
228
+ });
229
+ return fresh;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get cached game data from KV, or fetch fresh if missing.
237
+ */
238
+ async getGameDataFromKV(kv: KVNamespace): Promise<RefinedGameData | null> {
239
+ const cached = await kv.get<RefinedGameData>(KV_KEY_GAME_DATA, 'json');
240
+ if (cached) return cached;
241
+
242
+ try {
243
+ const fresh = await this.fetchAndRefineGameData();
244
+ await kv.put(KV_KEY_GAME_DATA, JSON.stringify(fresh), {
245
+ expirationTtl: DEFAULT_CACHE_TTL_SECONDS,
246
+ });
247
+ return fresh;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Get last sync timestamp from KV.
255
+ */
256
+ async getLastSync(kv: KVNamespace): Promise<string | null> {
257
+ return kv.get(KV_KEY_LAST_SYNC);
258
+ }
259
+ }
260
+
261
+ export const cocWikiDataImporter = new CocWikiDataImporter();