cortico-world-hearthstone 0.3.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.1
4
+
5
+ **装完就能用:自己把炉石的 Power 日志打开**
6
+
7
+ 上一版只自动关掉了日志大小上限,**没管日志开没开** —— 而炉石的 Power 日志默认是关的,
8
+ 开关在用户目录:`%LOCALAPPDATA%\Blizzard\Hearthstone\log.config`。新机器上这个文件
9
+ 压根不存在,很多人也从没建过。
10
+
11
+ 症状很坑:`Power.log` 照样存在、照样能读、照样能监听,但里面只有几行错误,
12
+ 一行对局数据都没有。**它不报错**,所以看起来像「她一句话都不说」。实测新机器上是
13
+ 6 行 `SubSpellController ... Failed to load spell prefab`,`CREATE_GAME` 一次都没有。
14
+
15
+ 现在启动时检查 `[Power]` 段,缺了就补上(`LogLevel=1`、`FilePrinting=true`):
16
+
17
+ - 只动 `[Power]` 这一段,画质、分辨率、账号缓存那些一个字节都不碰
18
+ - 改之前留 `log.config.bak`,备份失败就不改
19
+ - 先写临时文件再改名,免得炉石正好启动读到半截配置(半截 ini 会被整段忽略,等于没开)
20
+ - 补完要**重启一次炉石**才生效
21
+
22
+ **状态灯不再说谎**
23
+
24
+ 日志文件在、但一行对局数据都没有时,以前照样亮绿灯写「已连上日志」。现在分两种情况:
25
+
26
+ | 情况 | 灯 | 说什么 |
27
+ |---|---|---|
28
+ | 配置是这次才补的(还没重启炉石) | 红 | 「Power 日志之前没开,配置已经补好,重启一次炉石就生效」 |
29
+ | 配置本来就开着,只是还没进对局 | 绿 | 「文件在,但里面还没有对局数据——炉石刚启动,或还没进对局」 |
30
+
31
+ 工具回执也直说原因,不再拿「现在没有进行中的对局」糊过去 —— 那句话会把正在打的人
32
+ 带进「我明明在打」的死胡同。
33
+
3
34
  ## 0.3.0
4
35
 
5
36
  首个公开发布版本。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-hearthstone",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Cortico World: 陪打炉石 —— 读对局日志,知道双方的血量、场面、手牌与水晶,在关键节点开口。只看不动游戏,不代打。需要先打开炉石的 Power 日志。",
6
6
  "author": {
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/world.ts CHANGED
@@ -21,7 +21,10 @@ 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 { describeLimit, ensureLogSizeUnlimited, installDirFromLog } from './log-config.ts';
24
+ import {
25
+ describeLimit, describePowerLogging, ensureLogSizeUnlimited, ensurePowerLogging, installDirFromLog,
26
+ logConfigPath, type PowerLogResult,
27
+ } from './log-config.ts';
25
28
  import { advise, buildModelPrompt, renderAdvice } from './advice.ts';
26
29
  import { askModel, pickKey, type LlmConfig } from './llm.ts';
27
30
  import { composeTurnEvent, settleTurnWatermark, shouldAnnounceTurn } from './turn-event.ts';
@@ -289,6 +292,13 @@ export class HearthstoneWorld implements World {
289
292
  private readonly parser = new PowerLogParser(nowIso);
290
293
  private tail: PowerLogTail | null = null;
291
294
  private status: WatchStatus = { phase: 'no-log', path: null, note: '还没开始读日志。' };
295
+ /**
296
+ * start() 时处理 log.config 的结果。
297
+ *
298
+ * 补过(changed=true)就说明这次还没重启过炉石 —— 在用户重启之前,
299
+ * 日志里不可能有对局数据,这时亮红灯是**如实报告**,不是误报。
300
+ */
301
+ private powerLog: PowerLogResult | null = null;
292
302
  private chain: Promise<void> = Promise.resolve();
293
303
  private lastSnapshot: GameSnapshot | null = null;
294
304
  private announcedStart = false;
@@ -372,6 +382,15 @@ export class HearthstoneWorld implements World {
372
382
  async start(host: WorldHost): Promise<void> {
373
383
  this.host = host;
374
384
  this.log = host.log;
385
+ // Power 日志默认是**关的**:开关在用户目录的 log.config 里,很多人从来没建过。
386
+ // 没开的时候炉石只往 Power.log 写错误行,扩展读到的是个空壳——而且不报错,
387
+ // 用户只会看到「她一句话不说」。和上面关日志上限是同一类事:装完即用,别让人翻 ini。
388
+ this.powerLog = ensurePowerLogging();
389
+ if (this.powerLog.problem) {
390
+ this.log?.warn(`炉石的 Power 日志开关没能处理:${this.powerLog.problem}`);
391
+ } else {
392
+ this.log?.info(`Power 日志:${describePowerLogging(this.powerLog)}`);
393
+ }
375
394
  this.parser.setHistoryLimit(this.opts.cfg.historySize);
376
395
  this.tail = new PowerLogTail({
377
396
  getPath: () => resolvePowerLog({ logPath: this.cfg.logPath, installDir: this.cfg.installDir }),
@@ -457,12 +476,28 @@ export class HearthstoneWorld implements World {
457
476
  // 用快照判,不要用 parser.inGame —— 后者只表示「日志里有过一局」,
458
477
  // 那局已经打完它也是 true,于是灯一直亮成「进行中」,她也会以为自己还在牌桌上。
459
478
  const s = this.parser.snapshot();
479
+ if (s) {
480
+ this.status = {
481
+ phase: s.ended ? 'ended' : 'in-game',
482
+ path,
483
+ note: s.ended ? '这局已经结束。' : `第 ${s.turn} 回合。`,
484
+ };
485
+ return;
486
+ }
487
+ // 文件在、却一行对局数据都没有:要么炉石刚启动还没进对局,要么 Power 日志根本没开。
488
+ // 这两种必须分开 —— 前者一开打就恢复,后者得重启炉石,红灯不能乱亮。
489
+ // 判据用 powerLog.changed:配置是这次才补上的,那就一定还没重启过,亮红灯是如实报告。
490
+ if (looksLikePowerLog(path)) {
491
+ this.status = { phase: 'watching', path, note: '已连上日志。' };
492
+ return;
493
+ }
494
+ const waitingRestart = this.powerLog?.changed === true;
460
495
  this.status = {
461
- phase: s ? (s.ended ? 'ended' : 'in-game') : 'watching',
496
+ phase: waitingRestart ? 'no-log' : 'watching',
462
497
  path,
463
- note: s
464
- ? (s.ended ? '这局已经结束。' : `第 ${s.turn} 回合。`)
465
- : (looksLikePowerLog(path) ? '已连上日志。' : '文件在,但里面还没有对局数据——炉石可能刚启动,或 Power 日志没开全。'),
498
+ note: waitingRestart
499
+ ? 'Power 日志之前没开,配置已经补好,重启一次炉石就生效。'
500
+ : '文件在,但里面还没有对局数据——炉石刚启动,或还没进对局。',
466
501
  };
467
502
  }
468
503
 
@@ -1225,6 +1260,16 @@ export class HearthstoneWorld implements World {
1225
1260
  '要让她看得到,得先打开炉石的日志:在 %LOCALAPPDATA%\\Blizzard\\Hearthstone\\log.config 里写 [Power] 段(LogLevel=1、FilePrinting=true),然后重启炉石。装到别的盘的话,在设置里填一下「炉石安装目录」。',
1226
1261
  ].join('\n');
1227
1262
  }
1263
+ // 日志文件在、但里面没有对局数据:最常见的原因不是「没在打」,是 Power 日志压根没开。
1264
+ // 这时候只说「现在没有进行中的对局」等于把用户往「我明明在打」的死胡同里带。
1265
+ if (this.powerLog?.changed) {
1266
+ const where = this.powerLog.configPath || logConfigPath();
1267
+ return [
1268
+ `现在看不到对局:日志在 ${this.status.path},但里面一行对局数据都没有。`,
1269
+ `原因是炉石的 Power 日志之前没开。已经补好 ${where}([Power] 段:LogLevel=1、FilePrinting=true),**重启一次炉石就生效**。`,
1270
+ '之前已经打的对局补不回来 —— 那段时间炉石根本没往文件里写。',
1271
+ ].join('\n');
1272
+ }
1228
1273
  return `现在没有进行中的对局。日志在:${this.status.path}。${this.status.note}`;
1229
1274
  }
1230
1275