cortico-world-hearthstone 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/package.json +1 -1
- package/src/config.ts +16 -0
- package/src/log-config.ts +173 -0
- package/src/log-source.ts +34 -3
- package/src/peer.ts +56 -0
- package/src/world.ts +95 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
**听歌时让路(可选,默认关)**
|
|
6
|
+
|
|
7
|
+
设置页「高级」里多了一个开关。打开后,检测到 QQ 音乐 / 网易云在跑,就按安静处理:
|
|
8
|
+
不主动解说回合和告警,你问的时候照答,关掉播放器自动恢复。
|
|
9
|
+
|
|
10
|
+
默认关是有意的 —— 两件事同时进行、各说各的,很多人就喜欢那样,不该替人决定。
|
|
11
|
+
觉得吵再打开。注意:播放器挂着没放也算「在跑」,这时同样会安静。
|
|
12
|
+
|
|
13
|
+
**顺带修掉一个会让她「突然不说话」的问题**
|
|
14
|
+
|
|
15
|
+
判断「日志文件被重写」的旧条件太松:读完 position 正好等于文件大小是常态,
|
|
16
|
+
而炉石每写一次都会改 mtime,于是每轮都可能被误判成「文件被重写」——
|
|
17
|
+
一旦误判,读取位置归零、整份日志同步重读一遍,而重读进来的回合会被去重挡掉,
|
|
18
|
+
**外面看就是整段静默**。
|
|
19
|
+
|
|
20
|
+
现在加了文件头 256 字节的指纹比对:只有内容真的变了才当重写。原有那条
|
|
21
|
+
「同样大小被整体重写」的用例仍然通过。
|
|
22
|
+
|
|
23
|
+
## 0.3.1
|
|
24
|
+
|
|
25
|
+
**装完就能用:自己把炉石的 Power 日志打开**
|
|
26
|
+
|
|
27
|
+
上一版只自动关掉了日志大小上限,**没管日志开没开** —— 而炉石的 Power 日志默认是关的,
|
|
28
|
+
开关在用户目录:`%LOCALAPPDATA%\Blizzard\Hearthstone\log.config`。新机器上这个文件
|
|
29
|
+
压根不存在,很多人也从没建过。
|
|
30
|
+
|
|
31
|
+
症状很坑:`Power.log` 照样存在、照样能读、照样能监听,但里面只有几行错误,
|
|
32
|
+
一行对局数据都没有。**它不报错**,所以看起来像「她一句话都不说」。实测新机器上是
|
|
33
|
+
6 行 `SubSpellController ... Failed to load spell prefab`,`CREATE_GAME` 一次都没有。
|
|
34
|
+
|
|
35
|
+
现在启动时检查 `[Power]` 段,缺了就补上(`LogLevel=1`、`FilePrinting=true`):
|
|
36
|
+
|
|
37
|
+
- 只动 `[Power]` 这一段,画质、分辨率、账号缓存那些一个字节都不碰
|
|
38
|
+
- 改之前留 `log.config.bak`,备份失败就不改
|
|
39
|
+
- 先写临时文件再改名,免得炉石正好启动读到半截配置(半截 ini 会被整段忽略,等于没开)
|
|
40
|
+
- 补完要**重启一次炉石**才生效
|
|
41
|
+
|
|
42
|
+
**状态灯不再说谎**
|
|
43
|
+
|
|
44
|
+
日志文件在、但一行对局数据都没有时,以前照样亮绿灯写「已连上日志」。现在分两种情况:
|
|
45
|
+
|
|
46
|
+
| 情况 | 灯 | 说什么 |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| 配置是这次才补的(还没重启炉石) | 红 | 「Power 日志之前没开,配置已经补好,重启一次炉石就生效」 |
|
|
49
|
+
| 配置本来就开着,只是还没进对局 | 绿 | 「文件在,但里面还没有对局数据——炉石刚启动,或还没进对局」 |
|
|
50
|
+
|
|
51
|
+
工具回执也直说原因,不再拿「现在没有进行中的对局」糊过去 —— 那句话会把正在打的人
|
|
52
|
+
带进「我明明在打」的死胡同。
|
|
53
|
+
|
|
3
54
|
## 0.3.0
|
|
4
55
|
|
|
5
56
|
首个公开发布版本。
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -31,6 +31,16 @@ export interface HearthstoneConfigSection extends WorldSection {
|
|
|
31
31
|
speakAlerts: boolean;
|
|
32
32
|
/** normal = 按上面两项自己开口;quiet = 只看不说,等被问。 */
|
|
33
33
|
mode: 'normal' | 'quiet';
|
|
34
|
+
/**
|
|
35
|
+
* 听歌时让路。
|
|
36
|
+
*
|
|
37
|
+
* 打开后:检测到 QQ 音乐 / 网易云在跑,就按 `quiet` 处理——不主动解说回合和告警,
|
|
38
|
+
* 你问的时候照答。让的是「主动开口」这一层,工具调用不受影响。
|
|
39
|
+
*
|
|
40
|
+
* **默认关。** 两件事同时进行、各说各的,这是很多人喜欢的状态。
|
|
41
|
+
* 看到进程也不等于真在放歌,挂着不动也会让路。
|
|
42
|
+
*/
|
|
43
|
+
yieldToMusic: boolean;
|
|
34
44
|
/** 残血阈值:生命值降到这个数以下算告警。 */
|
|
35
45
|
alertHealth: number;
|
|
36
46
|
/** 对手场上随从达到这个数算被压场。 */
|
|
@@ -64,6 +74,7 @@ export const HEARTHSTONE_DEFAULTS = (): HearthstoneConfigSection => ({
|
|
|
64
74
|
adviceInTurn: true,
|
|
65
75
|
speakAlerts: true,
|
|
66
76
|
mode: 'normal',
|
|
77
|
+
yieldToMusic: false,
|
|
67
78
|
alertHealth: 10,
|
|
68
79
|
swarmCount: 4,
|
|
69
80
|
historySize: 200,
|
|
@@ -120,6 +131,11 @@ export function hearthstoneConfigGroups(): ConfigGroup[] {
|
|
|
120
131
|
title: '高级(一般不用动)',
|
|
121
132
|
description: '阈值和模型。默认值是按真实对局调过的,没遇到具体问题就别改。',
|
|
122
133
|
properties: {
|
|
134
|
+
'worlds.hearthstone.yieldToMusic': {
|
|
135
|
+
type: 'boolean',
|
|
136
|
+
title: '听歌时让路',
|
|
137
|
+
description: '打开后:检测到 QQ 音乐 / 网易云在跑,就不主动解说(回合提醒和告警都停),你问的时候照答;关掉播放器自动恢复。默认关——两件事同时进行、各说各的,很多人反而喜欢。注意:播放器挂着没放也算「在跑」,这时同样会安静。',
|
|
138
|
+
},
|
|
123
139
|
'worlds.hearthstone.alertHealth': {
|
|
124
140
|
type: 'integer',
|
|
125
141
|
title: '残血线',
|
package/src/log-config.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 所以唯一的正解是在它启动之前把上限关掉 —— 代价只是最后重启一次炉石。
|
|
24
24
|
*/
|
|
25
25
|
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
26
27
|
import { basename, dirname, join } from 'node:path';
|
|
27
28
|
|
|
28
29
|
/** 炉石默认的日志上限(KB)。 */
|
|
@@ -237,3 +238,175 @@ export function describeLimit(before: number | null): string {
|
|
|
237
238
|
if (before === -1) return '已经关掉上限';
|
|
238
239
|
return `${before}KB`;
|
|
239
240
|
}
|
|
241
|
+
|
|
242
|
+
/* ------------------------------------------------------------------ */
|
|
243
|
+
/* 另一件事:让炉石真的往 Power.log 里写对局数据 */
|
|
244
|
+
/* ------------------------------------------------------------------ */
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 炉石的 Power 日志默认是**关的**。
|
|
248
|
+
*
|
|
249
|
+
* `Power.log` 文件会存在(炉石启动时自己建),但里面只有错误行 —— 新机器上实测是
|
|
250
|
+
* 6 行 `SubSpellController ... Failed to load spell prefab`,`CREATE_GAME` 一次都没有。
|
|
251
|
+
* 开关在**用户目录**,不在安装目录:
|
|
252
|
+
*
|
|
253
|
+
* %LOCALAPPDATA%\Blizzard\Hearthstone\log.config
|
|
254
|
+
* [Power]
|
|
255
|
+
* LogLevel=1
|
|
256
|
+
* FilePrinting=true
|
|
257
|
+
*
|
|
258
|
+
* 没有这个文件就不写 GameState,扩展读到的永远是个空壳。
|
|
259
|
+
*
|
|
260
|
+
* 更麻烦的是它**不报错**:文件在、能读、能监听,就是没有内容。用户看到的是「她一句话不说」,
|
|
261
|
+
* 而不是「日志没开」。所以这层自己补上 —— 装完即用,不能让用户去翻 ini。
|
|
262
|
+
*
|
|
263
|
+
* 只动 `[Power]` 这一段,别的段一个字节都不碰;编码走 latin1 逐字节保真。
|
|
264
|
+
*/
|
|
265
|
+
export interface PowerLogResult {
|
|
266
|
+
configPath: string;
|
|
267
|
+
/** 动手之前这个文件存在吗。 */
|
|
268
|
+
existed: boolean;
|
|
269
|
+
/** 动手之前的状态。 */
|
|
270
|
+
before: 'ok' | 'incomplete' | 'absent';
|
|
271
|
+
/** 这次有没有真的写文件。 */
|
|
272
|
+
changed: boolean;
|
|
273
|
+
/** 没改成功的原因。 */
|
|
274
|
+
problem?: string;
|
|
275
|
+
backup?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 炉石日志配置的位置(用户目录,与安装目录无关)。 */
|
|
279
|
+
export function logConfigPath(): string {
|
|
280
|
+
const local = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local');
|
|
281
|
+
return join(local, 'Blizzard', 'Hearthstone', 'log.config');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `[Power]` 段里这两项齐了,炉石才把对局写进文件。 */
|
|
285
|
+
const POWER_WANT: ReadonlyArray<readonly [string, string]> = [
|
|
286
|
+
['LogLevel', '1'],
|
|
287
|
+
['FilePrinting', 'true'],
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
/** 读一遍配置,判断 `[Power]` 段够不够用。 */
|
|
291
|
+
export function powerLoggingState(text: string): 'ok' | 'incomplete' {
|
|
292
|
+
const seen = new Map<string, string>();
|
|
293
|
+
let section = '';
|
|
294
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
295
|
+
const line = raw.trim();
|
|
296
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
297
|
+
const sec = /^\[(.+)\]$/.exec(line);
|
|
298
|
+
if (sec) {
|
|
299
|
+
section = sec[1].trim().toLowerCase();
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const eq = line.indexOf('=');
|
|
303
|
+
if (eq < 0 || section !== 'power') continue;
|
|
304
|
+
seen.set(line.slice(0, eq).trim().toLowerCase(), line.slice(eq + 1).trim().toLowerCase());
|
|
305
|
+
}
|
|
306
|
+
return POWER_WANT.every(([k, v]) => seen.get(k.toLowerCase()) === v) ? 'ok' : 'incomplete';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 把 `[Power]` 段补成能用的样子,其余原样:
|
|
311
|
+
* 已有的键只改值,缺的键插在该段末尾,整段都没有就在文件末尾补一段。
|
|
312
|
+
*/
|
|
313
|
+
export function patchPowerLogging(text: string): { text: string; changed: boolean } {
|
|
314
|
+
if (powerLoggingState(text) === 'ok') return { text, changed: false };
|
|
315
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
316
|
+
const lines = text.length ? text.split(/\r?\n/) : [];
|
|
317
|
+
|
|
318
|
+
let section = '';
|
|
319
|
+
let powerAt = -1;
|
|
320
|
+
let lastInPower = -1;
|
|
321
|
+
const have = new Set<string>();
|
|
322
|
+
|
|
323
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
324
|
+
const line = lines[i].trim();
|
|
325
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
326
|
+
const sec = /^\[(.+)\]$/.exec(line);
|
|
327
|
+
if (sec) {
|
|
328
|
+
section = sec[1].trim().toLowerCase();
|
|
329
|
+
if (section === 'power') powerAt = i;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const eq = line.indexOf('=');
|
|
333
|
+
if (eq < 0 || section !== 'power') continue;
|
|
334
|
+
lastInPower = i;
|
|
335
|
+
const key = line.slice(0, eq).trim().toLowerCase();
|
|
336
|
+
const want = POWER_WANT.find(([k]) => k.toLowerCase() === key);
|
|
337
|
+
if (!want) continue;
|
|
338
|
+
have.add(key);
|
|
339
|
+
if (line.slice(eq + 1).trim().toLowerCase() !== want[1]) {
|
|
340
|
+
lines[i] = `${lines[i].slice(0, eq)}=${want[1]}`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const missing = POWER_WANT.filter(([k]) => !have.has(k.toLowerCase()));
|
|
345
|
+
if (powerAt >= 0) {
|
|
346
|
+
const at = (lastInPower >= 0 ? lastInPower : powerAt) + 1;
|
|
347
|
+
lines.splice(at, 0, ...missing.map(([k, v]) => `${k}=${v}`));
|
|
348
|
+
return { text: lines.join(eol), changed: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const block = ['[Power]', ...POWER_WANT.map(([k, v]) => `${k}=${v}`)];
|
|
352
|
+
const tail = text.length && !text.endsWith('\n') ? eol : '';
|
|
353
|
+
const gap = text.length ? eol : '';
|
|
354
|
+
return { text: `${text}${tail}${gap}${block.join(eol)}${eol}`, changed: true };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 确保炉石会把对局写进 `Power.log`。
|
|
359
|
+
*
|
|
360
|
+
* 与 `ensureLogSizeUnlimited` 同一套谨慎标准:先备份,写的时候先临时文件再改名 ——
|
|
361
|
+
* 免得炉石正好启动读到半截配置(半截 ini 会让它整段忽略,等于没开)。
|
|
362
|
+
*/
|
|
363
|
+
export function ensurePowerLogging(configPath = logConfigPath()): PowerLogResult {
|
|
364
|
+
const existed = existsSync(configPath);
|
|
365
|
+
const text = readText(configPath);
|
|
366
|
+
const base: PowerLogResult = {
|
|
367
|
+
configPath,
|
|
368
|
+
existed,
|
|
369
|
+
before: text === null ? 'absent' : powerLoggingState(text),
|
|
370
|
+
changed: false,
|
|
371
|
+
};
|
|
372
|
+
if (text === null && existed) return { ...base, problem: 'log.config 读不出来(权限或格式)' };
|
|
373
|
+
|
|
374
|
+
const patched = patchPowerLogging(text ?? '');
|
|
375
|
+
if (!patched.changed) return base;
|
|
376
|
+
|
|
377
|
+
let backup: string | undefined;
|
|
378
|
+
if (text !== null) {
|
|
379
|
+
const bak = `${configPath}.bak`;
|
|
380
|
+
if (!existsSync(bak)) {
|
|
381
|
+
try {
|
|
382
|
+
copyFileSync(configPath, bak);
|
|
383
|
+
backup = bak;
|
|
384
|
+
} catch {
|
|
385
|
+
return { ...base, problem: '备份 log.config 失败,没敢改' };
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
backup = bak;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const tmp = `${configPath}.tmp`;
|
|
394
|
+
writeFileSync(tmp, patched.text, 'latin1');
|
|
395
|
+
renameSync(tmp, configPath);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return { ...base, backup, problem: `写不进去(${(error as Error).message})` };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { ...base, changed: true, backup };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 供诊断文案用:一句话交代日志开没开。 */
|
|
404
|
+
export function describePowerLogging(result: PowerLogResult): string {
|
|
405
|
+
if (result.problem) return `开不起来:${result.problem}`;
|
|
406
|
+
if (result.changed) {
|
|
407
|
+
return result.before === 'absent'
|
|
408
|
+
? '之前没有 log.config,已建好(重启炉石后生效)'
|
|
409
|
+
: '之前 [Power] 段不全,已补齐(重启炉石后生效)';
|
|
410
|
+
}
|
|
411
|
+
return 'Power 日志已开着';
|
|
412
|
+
}
|
package/src/log-source.ts
CHANGED
|
@@ -169,6 +169,8 @@ export class PowerLogTail {
|
|
|
169
169
|
private failures = 0;
|
|
170
170
|
/** 上一次看到的修改时间:用来识别「文件被整体重写成同样大小」这种只比长度抓不到的情况。 */
|
|
171
171
|
private lastMtimeMs = 0;
|
|
172
|
+
/** 文件开头 256 字节的内容指纹。只在怀疑被整体重写时读一次,不是每轮都读。 */
|
|
173
|
+
private headSig = '';
|
|
172
174
|
/** 上次重新扫目录找日志的时刻。 */
|
|
173
175
|
private lastResolveAt = 0;
|
|
174
176
|
/** 上次定位的结果(含「没找到」)。缓存到下一次重扫。 */
|
|
@@ -250,6 +252,8 @@ export class PowerLogTail {
|
|
|
250
252
|
this.decoder = new StringDecoder('utf8');
|
|
251
253
|
this.partial = '';
|
|
252
254
|
this.lastMtimeMs = 0;
|
|
255
|
+
// 换了文件,旧指纹作废,下一轮重新取
|
|
256
|
+
this.headSig = '';
|
|
253
257
|
this.opts.log?.info(`开始读炉石日志:${nextPath}(${found?.why ?? ''},从第 ${this.offset} 字节起)`);
|
|
254
258
|
}
|
|
255
259
|
this.opts.onSwitch?.(nextPath);
|
|
@@ -262,18 +266,45 @@ export class PowerLogTail {
|
|
|
262
266
|
this.drain(nextPath);
|
|
263
267
|
}
|
|
264
268
|
|
|
269
|
+
/** 读文件开头一小段当指纹。读不到返回空串(空串不会等于任何已有指纹,因此不会误起重写)。 */
|
|
270
|
+
private headSignature(path: string): string {
|
|
271
|
+
try {
|
|
272
|
+
const size = statSize(path);
|
|
273
|
+
if (size <= 0) return '';
|
|
274
|
+
const fd = openSync(path, 'r');
|
|
275
|
+
const buf = Buffer.allocUnsafe(Math.min(256, size));
|
|
276
|
+
try {
|
|
277
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
278
|
+
} finally {
|
|
279
|
+
closeSync(fd);
|
|
280
|
+
}
|
|
281
|
+
return buf.toString('latin1');
|
|
282
|
+
} catch {
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
265
287
|
private drain(path: string, initial = false): void {
|
|
266
288
|
const stat = statOrNull(path);
|
|
267
289
|
if (!stat) return;
|
|
268
290
|
const { size, mtimeMs } = stat;
|
|
269
291
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
292
|
+
// 「文件变短」一定是被换掉了。
|
|
293
|
+
//
|
|
294
|
+
// 「长度没变但 mtime 动了」必须再确认**内容真的变了**才算重写,否则正常轮询会误判 ——
|
|
295
|
+
// 读完 offset 正好等于 size 是常态,而炉石每写一次都会改 mtime。误判的代价是 offset 归零、
|
|
296
|
+
// 整份日志同步重读一遍,而重读进来的回合早被解析器 `lastQueuedTurn` 去重挡掉,
|
|
297
|
+
// **外面看着就是炉石这条链路突然不出声**(2026-10-27 实测:第 21、23 回合和结束事件
|
|
298
|
+
// 就这么丢的)。所以这里加一道 256 字节的内容比对,只在可疑时读一次。
|
|
299
|
+
const truncated = size < this.offset;
|
|
300
|
+
if (!this.headSig) this.headSig = this.headSignature(path);
|
|
301
|
+
const suspect = !truncated && size === this.offset && size > 0 && mtimeMs !== this.lastMtimeMs;
|
|
302
|
+
const rewritten = truncated || (suspect && this.headSignature(path) !== this.headSig);
|
|
273
303
|
if (rewritten && this.offset > 0) {
|
|
274
304
|
this.offset = startOffsetFor(path, size);
|
|
275
305
|
this.decoder = new StringDecoder('utf8');
|
|
276
306
|
this.partial = '';
|
|
307
|
+
this.headSig = this.headSignature(path);
|
|
277
308
|
}
|
|
278
309
|
this.lastMtimeMs = mtimeMs;
|
|
279
310
|
|
package/src/peer.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对端扩展对应的程序在不在跑。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这一层:两个扩展装在同一个宿主里,各推各的事件,**互相不认识** ——
|
|
5
|
+
* 宿主只给了「往同一个对话推事件」这一条路,没有 world 之间互查状态的接口。
|
|
6
|
+
* 而「听歌的时候别解说」这种需求是真的,进程列表就是唯一不需要跨扩展通信、
|
|
7
|
+
* 也不用改宿主就能拿到的信号。
|
|
8
|
+
*
|
|
9
|
+
* 代价要说清楚:**看到进程 ≠ 正在听**。播放器挂着没放也算在跑,这时炉石同样会安静。
|
|
10
|
+
* 所以它只能躲在用户自己打开的开关后面,不能默认开。
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
const run = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
/** 缓存时长:tasklist 一次要一两百毫秒,不能每次要判断都跑一遍。 */
|
|
18
|
+
const TTL_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
let lastAt = 0;
|
|
21
|
+
let lastHit = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 名单里任一进程在跑就返回 true。
|
|
25
|
+
*
|
|
26
|
+
* 查询失败时返回 false(当作没在跑)—— 这个方向是刻意的:宁可多说几句,
|
|
27
|
+
* 也不要因为一次 tasklist 失败就让她整场闭麦,那种「突然不说话」最难排查。
|
|
28
|
+
*/
|
|
29
|
+
export async function isPeerRunning(names: readonly string[]): Promise<boolean> {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (now - lastAt < TTL_MS) return lastHit;
|
|
32
|
+
|
|
33
|
+
let hit = false;
|
|
34
|
+
try {
|
|
35
|
+
// /FO CSV 一行一个进程,形如 "QQMusic.exe","1234","Console","1","1,234 K"
|
|
36
|
+
const { stdout } = await run('tasklist', ['/NH', '/FO', 'CSV'], {
|
|
37
|
+
timeout: 4000,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
maxBuffer: 4 << 20,
|
|
40
|
+
});
|
|
41
|
+
const lower = stdout.toLowerCase();
|
|
42
|
+
hit = names.some((name) => lower.includes(`"${name.toLowerCase()}"`));
|
|
43
|
+
} catch {
|
|
44
|
+
hit = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lastAt = now;
|
|
48
|
+
lastHit = hit;
|
|
49
|
+
return hit;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 测试用:把缓存清掉。 */
|
|
53
|
+
export function resetPeerCache(): void {
|
|
54
|
+
lastAt = 0;
|
|
55
|
+
lastHit = false;
|
|
56
|
+
}
|
package/src/world.ts
CHANGED
|
@@ -21,7 +21,11 @@ import { PowerLogTail, describeSearch, looksLikePowerLog, resolvePowerLog } from
|
|
|
21
21
|
import { PowerLogParser, renderSnapshot, type ActionRecord, type GameSnapshot, type SideState, type TurnPoint } from './parser.ts';
|
|
22
22
|
import { detectAlerts, endMarker, endText, faceOf, lethalText, startText, surpriseAlerts, toneOf } from './alerts.ts';
|
|
23
23
|
import { panelAdvice, panelAskText, panelStatus, type PanelAdvice, type PanelStatus } from './panel.ts';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
describeLimit, describePowerLogging, ensureLogSizeUnlimited, ensurePowerLogging, installDirFromLog,
|
|
26
|
+
logConfigPath, type PowerLogResult,
|
|
27
|
+
} from './log-config.ts';
|
|
28
|
+
import { isPeerRunning } from './peer.ts';
|
|
25
29
|
import { advise, buildModelPrompt, renderAdvice } from './advice.ts';
|
|
26
30
|
import { askModel, pickKey, type LlmConfig } from './llm.ts';
|
|
27
31
|
import { composeTurnEvent, settleTurnWatermark, shouldAnnounceTurn } from './turn-event.ts';
|
|
@@ -135,6 +139,7 @@ export interface HearthstoneWorldOptions {
|
|
|
135
139
|
|
|
136
140
|
type Phase = 'no-log' | 'watching' | 'in-game' | 'ended';
|
|
137
141
|
|
|
142
|
+
|
|
138
143
|
interface WatchStatus {
|
|
139
144
|
phase: Phase;
|
|
140
145
|
path: string | null;
|
|
@@ -289,6 +294,21 @@ export class HearthstoneWorld implements World {
|
|
|
289
294
|
private readonly parser = new PowerLogParser(nowIso);
|
|
290
295
|
private tail: PowerLogTail | null = null;
|
|
291
296
|
private status: WatchStatus = { phase: 'no-log', path: null, note: '还没开始读日志。' };
|
|
297
|
+
/**
|
|
298
|
+
* start() 时处理 log.config 的结果。
|
|
299
|
+
*
|
|
300
|
+
* 补过(changed=true)就说明这次还没重启过炉石 —— 在用户重启之前,
|
|
301
|
+
* 日志里不可能有对局数据,这时亮红灯是**如实报告**,不是误报。
|
|
302
|
+
*/
|
|
303
|
+
private powerLog: PowerLogResult | null = null;
|
|
304
|
+
/**
|
|
305
|
+
* 播放器在不在跑(「听歌时让路」用)。
|
|
306
|
+
*
|
|
307
|
+
* 由定时器维护、`onLines` 里同步读 —— 那边是同步函数,不能在里面 await tasklist。
|
|
308
|
+
* 开关没开时常为 false,探测也直接跳过,不白花这一两百毫秒。
|
|
309
|
+
*/
|
|
310
|
+
private musicRunning = false;
|
|
311
|
+
private peerTimer: ReturnType<typeof setInterval> | null = null;
|
|
292
312
|
private chain: Promise<void> = Promise.resolve();
|
|
293
313
|
private lastSnapshot: GameSnapshot | null = null;
|
|
294
314
|
private announcedStart = false;
|
|
@@ -369,9 +389,36 @@ export class HearthstoneWorld implements World {
|
|
|
369
389
|
|
|
370
390
|
/* ---------- 生命周期 ---------- */
|
|
371
391
|
|
|
392
|
+
/**
|
|
393
|
+
* 看一眼播放器在不在跑。只在「听歌时让路」打开时才真去查 tasklist。
|
|
394
|
+
*
|
|
395
|
+
* 单独拿出来是因为 `onLines` 是同步的 —— 那边不能 await,只能读这里维护的字段。
|
|
396
|
+
* 查询失败按「没在跑」处理:宁可多说几句,也不要因为一次查询失败整场闭麦。
|
|
397
|
+
*/
|
|
398
|
+
private async probePeers(): Promise<void> {
|
|
399
|
+
if (!this.cfg.yieldToMusic) {
|
|
400
|
+
this.musicRunning = false;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.musicRunning = await isPeerRunning(['QQMusic.exe', 'cloudmusic.exe']);
|
|
404
|
+
}
|
|
405
|
+
|
|
372
406
|
async start(host: WorldHost): Promise<void> {
|
|
373
407
|
this.host = host;
|
|
374
408
|
this.log = host.log;
|
|
409
|
+
// Power 日志默认是**关的**:开关在用户目录的 log.config 里,很多人从来没建过。
|
|
410
|
+
// 没开的时候炉石只往 Power.log 写错误行,扩展读到的是个空壳——而且不报错,
|
|
411
|
+
// 用户只会看到「她一句话不说」。和上面关日志上限是同一类事:装完即用,别让人翻 ini。
|
|
412
|
+
this.powerLog = ensurePowerLogging();
|
|
413
|
+
if (this.powerLog.problem) {
|
|
414
|
+
this.log?.warn(`炉石的 Power 日志开关没能处理:${this.powerLog.problem}`);
|
|
415
|
+
} else {
|
|
416
|
+
this.log?.info(`Power 日志:${describePowerLogging(this.powerLog)}`);
|
|
417
|
+
}
|
|
418
|
+
// 「听歌时让路」用:每 15 秒看一眼播放器在不在(开关没开就直接返回,不白跑 tasklist)。
|
|
419
|
+
void this.probePeers();
|
|
420
|
+
this.peerTimer = setInterval(() => void this.probePeers(), 15_000);
|
|
421
|
+
this.peerTimer.unref?.();
|
|
375
422
|
this.parser.setHistoryLimit(this.opts.cfg.historySize);
|
|
376
423
|
this.tail = new PowerLogTail({
|
|
377
424
|
getPath: () => resolvePowerLog({ logPath: this.cfg.logPath, installDir: this.cfg.installDir }),
|
|
@@ -395,6 +442,10 @@ export class HearthstoneWorld implements World {
|
|
|
395
442
|
}
|
|
396
443
|
|
|
397
444
|
async stop(): Promise<void> {
|
|
445
|
+
if (this.peerTimer) {
|
|
446
|
+
clearInterval(this.peerTimer);
|
|
447
|
+
this.peerTimer = null;
|
|
448
|
+
}
|
|
398
449
|
this.tail?.stop();
|
|
399
450
|
this.tail = null;
|
|
400
451
|
this.clearPendingEndTimer();
|
|
@@ -457,12 +508,28 @@ export class HearthstoneWorld implements World {
|
|
|
457
508
|
// 用快照判,不要用 parser.inGame —— 后者只表示「日志里有过一局」,
|
|
458
509
|
// 那局已经打完它也是 true,于是灯一直亮成「进行中」,她也会以为自己还在牌桌上。
|
|
459
510
|
const s = this.parser.snapshot();
|
|
511
|
+
if (s) {
|
|
512
|
+
this.status = {
|
|
513
|
+
phase: s.ended ? 'ended' : 'in-game',
|
|
514
|
+
path,
|
|
515
|
+
note: s.ended ? '这局已经结束。' : `第 ${s.turn} 回合。`,
|
|
516
|
+
};
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// 文件在、却一行对局数据都没有:要么炉石刚启动还没进对局,要么 Power 日志根本没开。
|
|
520
|
+
// 这两种必须分开 —— 前者一开打就恢复,后者得重启炉石,红灯不能乱亮。
|
|
521
|
+
// 判据用 powerLog.changed:配置是这次才补上的,那就一定还没重启过,亮红灯是如实报告。
|
|
522
|
+
if (looksLikePowerLog(path)) {
|
|
523
|
+
this.status = { phase: 'watching', path, note: '已连上日志。' };
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const waitingRestart = this.powerLog?.changed === true;
|
|
460
527
|
this.status = {
|
|
461
|
-
phase:
|
|
528
|
+
phase: waitingRestart ? 'no-log' : 'watching',
|
|
462
529
|
path,
|
|
463
|
-
note:
|
|
464
|
-
?
|
|
465
|
-
:
|
|
530
|
+
note: waitingRestart
|
|
531
|
+
? 'Power 日志之前没开,配置已经补好,重启一次炉石就生效。'
|
|
532
|
+
: '文件在,但里面还没有对局数据——炉石刚启动,或还没进对局。',
|
|
466
533
|
};
|
|
467
534
|
}
|
|
468
535
|
|
|
@@ -556,19 +623,21 @@ export class HearthstoneWorld implements World {
|
|
|
556
623
|
this.enqueue(() => this.push('hearthstone.game.start', 'hearthstone.game.start', `[happy]${startText(snapshot)}`, 'flush'));
|
|
557
624
|
}
|
|
558
625
|
|
|
559
|
-
|
|
626
|
+
// 「听歌时让路」:播放器在跑就按安静处理。让的是**主动解说**这一层(回合提醒、告警),
|
|
627
|
+
// 工具调用和被问时的回答不受影响 —— 让路不等于拒答。
|
|
628
|
+
const quiet = initial || this.cfg.mode === 'quiet' || this.musicRunning;
|
|
560
629
|
|
|
561
|
-
//
|
|
562
|
-
// 不能在批次边界判断 isMyTurn —— 一批跨几个回合时,批末早不是当时的局面了。
|
|
563
|
-
// 回合提醒:由**这一批结束时算出来的快照**驱动,不再靠标签回调排队。
|
|
564
|
-
//
|
|
565
|
-
// 为什么改:以前在 CURRENT_PLAYER / STEP 的回调里排队,那条路依赖
|
|
566
|
-
// `isMyTurnNow()`(按玩家名分桶)判断轮次。长时间跑下来桶会失准,结果是整整一局
|
|
567
|
-
// 一个回合事件都排不出来。快照里的 isMyTurn 和后面算建议用的是同一份数据,
|
|
568
|
-
// 不会出现「判定说轮到我、数据却显示是对手」。
|
|
630
|
+
// 回合事件由解析器**逐回合**记下来(每进一次我的行动阶段就记一个点),这里逐个取走再开口。
|
|
569
631
|
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
632
|
+
// 不能拿「这一批结束时的快照」代表整批 —— 那是以前的写法,也是漏报的病根。
|
|
633
|
+
// 同一份真实日志,只改批大小,实测:
|
|
634
|
+
// 批 400 行 → 报 1,3,5,...,23 共 12 次,全对
|
|
635
|
+
// 批 2000 行 → 只报 7 次,漏 5 个
|
|
636
|
+
// 批 6000 行 → 只报 2 次
|
|
637
|
+
// 批 15000 行 → 一次都不报
|
|
638
|
+
// 因为批末早不是那些回合的局面了:批尾落在对方回合或已结束时,中间所有回合一起被吞。
|
|
639
|
+
// 而 tail 一旦卡顿(文件被占用、休眠唤醒、大日志读得慢),积压就是几万行一起涌进来。
|
|
640
|
+
// 按队列补报与批大小无关,才是稳的。
|
|
572
641
|
if (!quiet && this.cfg.speakTurns && shouldAnnounceTurn(snapshot, this.lastAnnouncedTurn)) {
|
|
573
642
|
const point: TurnPoint = {
|
|
574
643
|
turn: snapshot.turn,
|
|
@@ -1225,6 +1294,16 @@ export class HearthstoneWorld implements World {
|
|
|
1225
1294
|
'要让她看得到,得先打开炉石的日志:在 %LOCALAPPDATA%\\Blizzard\\Hearthstone\\log.config 里写 [Power] 段(LogLevel=1、FilePrinting=true),然后重启炉石。装到别的盘的话,在设置里填一下「炉石安装目录」。',
|
|
1226
1295
|
].join('\n');
|
|
1227
1296
|
}
|
|
1297
|
+
// 日志文件在、但里面没有对局数据:最常见的原因不是「没在打」,是 Power 日志压根没开。
|
|
1298
|
+
// 这时候只说「现在没有进行中的对局」等于把用户往「我明明在打」的死胡同里带。
|
|
1299
|
+
if (this.powerLog?.changed) {
|
|
1300
|
+
const where = this.powerLog.configPath || logConfigPath();
|
|
1301
|
+
return [
|
|
1302
|
+
`现在看不到对局:日志在 ${this.status.path},但里面一行对局数据都没有。`,
|
|
1303
|
+
`原因是炉石的 Power 日志之前没开。已经补好 ${where}([Power] 段:LogLevel=1、FilePrinting=true),**重启一次炉石就生效**。`,
|
|
1304
|
+
'之前已经打的对局补不回来 —— 那段时间炉石根本没往文件里写。',
|
|
1305
|
+
].join('\n');
|
|
1306
|
+
}
|
|
1228
1307
|
return `现在没有进行中的对局。日志在:${this.status.path}。${this.status.note}`;
|
|
1229
1308
|
}
|
|
1230
1309
|
|