cortico-world-hearthstone 0.3.1 → 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 CHANGED
@@ -1,5 +1,25 @@
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
+
3
23
  ## 0.3.1
4
24
 
5
25
  **装完就能用:自己把炉石的 Power 日志打开**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-hearthstone",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Cortico World: 陪打炉石 —— 读对局日志,知道双方的血量、场面、手牌与水晶,在关键节点开口。只看不动游戏,不代打。需要先打开炉石的 Power 日志。",
6
6
  "author": {
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-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
- // 两种「文件被换掉」:变短了,或者长度没变但内容被整体重写(mtime 动了、长度没长)。
271
- // 只看 size 会漏掉后一种 —— 新一局恰好和上一局同样大小时就会安静地卡住。
272
- const rewritten = size < this.offset || (mtimeMs !== this.lastMtimeMs && size <= this.offset && size > 0);
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
@@ -25,6 +25,7 @@ import {
25
25
  describeLimit, describePowerLogging, ensureLogSizeUnlimited, ensurePowerLogging, installDirFromLog,
26
26
  logConfigPath, type PowerLogResult,
27
27
  } from './log-config.ts';
28
+ import { isPeerRunning } from './peer.ts';
28
29
  import { advise, buildModelPrompt, renderAdvice } from './advice.ts';
29
30
  import { askModel, pickKey, type LlmConfig } from './llm.ts';
30
31
  import { composeTurnEvent, settleTurnWatermark, shouldAnnounceTurn } from './turn-event.ts';
@@ -138,6 +139,7 @@ export interface HearthstoneWorldOptions {
138
139
 
139
140
  type Phase = 'no-log' | 'watching' | 'in-game' | 'ended';
140
141
 
142
+
141
143
  interface WatchStatus {
142
144
  phase: Phase;
143
145
  path: string | null;
@@ -299,6 +301,14 @@ export class HearthstoneWorld implements World {
299
301
  * 日志里不可能有对局数据,这时亮红灯是**如实报告**,不是误报。
300
302
  */
301
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;
302
312
  private chain: Promise<void> = Promise.resolve();
303
313
  private lastSnapshot: GameSnapshot | null = null;
304
314
  private announcedStart = false;
@@ -379,6 +389,20 @@ export class HearthstoneWorld implements World {
379
389
 
380
390
  /* ---------- 生命周期 ---------- */
381
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
+
382
406
  async start(host: WorldHost): Promise<void> {
383
407
  this.host = host;
384
408
  this.log = host.log;
@@ -391,6 +415,10 @@ export class HearthstoneWorld implements World {
391
415
  } else {
392
416
  this.log?.info(`Power 日志:${describePowerLogging(this.powerLog)}`);
393
417
  }
418
+ // 「听歌时让路」用:每 15 秒看一眼播放器在不在(开关没开就直接返回,不白跑 tasklist)。
419
+ void this.probePeers();
420
+ this.peerTimer = setInterval(() => void this.probePeers(), 15_000);
421
+ this.peerTimer.unref?.();
394
422
  this.parser.setHistoryLimit(this.opts.cfg.historySize);
395
423
  this.tail = new PowerLogTail({
396
424
  getPath: () => resolvePowerLog({ logPath: this.cfg.logPath, installDir: this.cfg.installDir }),
@@ -414,6 +442,10 @@ export class HearthstoneWorld implements World {
414
442
  }
415
443
 
416
444
  async stop(): Promise<void> {
445
+ if (this.peerTimer) {
446
+ clearInterval(this.peerTimer);
447
+ this.peerTimer = null;
448
+ }
417
449
  this.tail?.stop();
418
450
  this.tail = null;
419
451
  this.clearPendingEndTimer();
@@ -591,19 +623,21 @@ export class HearthstoneWorld implements World {
591
623
  this.enqueue(() => this.push('hearthstone.game.start', 'hearthstone.game.start', `[happy]${startText(snapshot)}`, 'flush'));
592
624
  }
593
625
 
594
- const quiet = initial || this.cfg.mode === 'quiet';
626
+ // 「听歌时让路」:播放器在跑就按安静处理。让的是**主动解说**这一层(回合提醒、告警),
627
+ // 工具调用和被问时的回答不受影响 —— 让路不等于拒答。
628
+ const quiet = initial || this.cfg.mode === 'quiet' || this.musicRunning;
595
629
 
596
- // 回合事件由解析器按「进入行动阶段」记下来,这里只管取走并开口。
597
- // 不能在批次边界判断 isMyTurn —— 一批跨几个回合时,批末早不是当时的局面了。
598
- // 回合提醒:由**这一批结束时算出来的快照**驱动,不再靠标签回调排队。
599
- //
600
- // 为什么改:以前在 CURRENT_PLAYER / STEP 的回调里排队,那条路依赖
601
- // `isMyTurnNow()`(按玩家名分桶)判断轮次。长时间跑下来桶会失准,结果是整整一局
602
- // 一个回合事件都排不出来。快照里的 isMyTurn 和后面算建议用的是同一份数据,
603
- // 不会出现「判定说轮到我、数据却显示是对手」。
630
+ // 回合事件由解析器**逐回合**记下来(每进一次我的行动阶段就记一个点),这里逐个取走再开口。
604
631
  //
605
- // 资源没刷新(水晶 0)时不更新 lastAnnouncedTurn,下一批自然重试;报过就不再报,
606
- // 所以同一回合不会重复开口。
632
+ // 不能拿「这一批结束时的快照」代表整批 —— 那是以前的写法,也是漏报的病根。
633
+ // 同一份真实日志,只改批大小,实测:
634
+ // 批 400 行 → 报 1,3,5,...,23 共 12 次,全对
635
+ // 批 2000 行 → 只报 7 次,漏 5 个
636
+ // 批 6000 行 → 只报 2 次
637
+ // 批 15000 行 → 一次都不报
638
+ // 因为批末早不是那些回合的局面了:批尾落在对方回合或已结束时,中间所有回合一起被吞。
639
+ // 而 tail 一旦卡顿(文件被占用、休眠唤醒、大日志读得慢),积压就是几万行一起涌进来。
640
+ // 按队列补报与批大小无关,才是稳的。
607
641
  if (!quiet && this.cfg.speakTurns && shouldAnnounceTurn(snapshot, this.lastAnnouncedTurn)) {
608
642
  const point: TurnPoint = {
609
643
  turn: snapshot.turn,