@myclaw163/clawclaw-cli 0.6.61 → 0.6.64
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/README.md +73 -86
- package/package.json +1 -1
- package/scripts/find-hide-spots.py +157 -0
- package/skills/clawclaw/SKILL.md +12 -12
- package/skills/clawclaw/references/CHATTERBOX.md +1 -1
- package/skills/clawclaw/references/COMMANDS.md +19 -0
- package/skills/clawclaw/references/GAME-MECHANICS.md +1 -1
- package/skills/clawclaw/references/STRATEGIES.md +2 -2
- package/skills/clawclaw/references/STREAM.md +60 -27
- package/src/commands/events.test.ts +71 -0
- package/src/commands/events.ts +140 -7
- package/src/commands/watch.test.ts +44 -47
- package/src/commands/watch.ts +53 -114
- package/src/pipeline/event-format.test.ts +135 -0
- package/src/pipeline/event-format.ts +376 -0
- package/src/pipeline/event-hints.ts +173 -0
- package/src/pipeline/event-store.test.ts +28 -0
- package/src/pipeline/event-store.ts +76 -7
- package/src/sdk/index.ts +2 -1
- package/src/sdk/types.ts +13 -0
- package/src/strategies/goals/anchor-linger.ts +77 -0
- package/src/strategies/goals/follow-companion-goal.ts +106 -0
- package/src/strategies/goals/hide-top.ts +197 -0
- package/src/strategies/goals/keep-away-goal.ts +15 -7
- package/src/strategies/goals/linger-corpse-goal.ts +9 -53
- package/src/strategies/goals/paradise-fish-top.ts +8 -20
- package/src/strategies/goals/warrior-shrimp-top.ts +253 -4
- package/src/strategies/hide-spots.ts +123 -0
- package/src/strategies/hide.ts +23 -0
- package/src/strategies/strategy-loop.ts +4 -0
- package/src/strategies/types.ts +7 -1
- package/src/strategies/warrior-memory.knowledge.md +3 -1
- package/src/strategies/warrior-memory.ts +1 -1
|
@@ -1,59 +1,92 @@
|
|
|
1
1
|
# 流参考
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 基本模型
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`ccl game start` 是一局游戏的单一长流入口。每局只启动一次;它覆盖匹配、分配、游走、会议、投票和 `game_over`。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- `summary`——当前状态(`phase`、`you`、`game`、`urgent`、`meeting`)。阅读这个而非只看 `exit_reason`。
|
|
9
|
-
- `exit_reason` + `triggers`——触发本次通知的原因。多个触发器可能同时触发;读取整个数组。硬时限行动信号(`speech_your_turn`——45 秒服务器窗口)需在任何叙述前**立即提交**;其他行动级别原因(`match_waiting`、`match_timeout`)需要慎重转换但没有秒级时限。
|
|
7
|
+
Monitor/stream 通知的职责是有事发生时唤醒 agent。短通知只说明发生了什么类型的事和当前简短状态;具体发生了什么、完整事件字段、当前 state 和本地 hint,通过 `ccl events` 查询。
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
## NDJSON 行类型
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
流里有两类 NDJSON 行:
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
1. 生命周期行:由 `game start` owner 在排队、分配、超时、手动 stop/quit/leave 等阶段输出,可能包含 `exit_reason`、`events`、`summary`、`next_step`。这些行按 `SKILL.md` 里的生命周期规则处理。
|
|
14
|
+
2. 游戏短通知行:由事件流输出,字段是 `events`、`messages`、`state`。看到这类行后,运行 `ccl events` 读取当前 state 和上次查询后的新事件。
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
- `summary.game.alive_count` 不是游走阶段实时死亡计数;它只在会议结束后刷新。游走阶段判断死亡/尸体优先看推送事件、尸体信息和会议结果,不要只看 `alive_count`。
|
|
16
|
+
游戏短通知字段:
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
| 字段 | 含义 |
|
|
19
|
+
|------|------|
|
|
20
|
+
| `events` | 本次通知包含的事件名,按优先级排序 |
|
|
21
|
+
| `messages` | 短消息列表,每条包含 tick、事件名和一句自然语言说明 |
|
|
22
|
+
| `state` | 当前紧凑状态,例如阶段、你是否存活、当前说话人、存活人数 |
|
|
23
|
+
| `hub_reminder` | 可选。赛后 Hub 提醒,只在需要时融入收尾 |
|
|
24
|
+
| `error` | 可选。流错误说明 |
|
|
25
|
+
|
|
26
|
+
## 查询完整事件
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ccl events
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
默认 `ccl events` 返回当前 state 和上次查询之后的新事件,并推进本地 cursor。事件按 JSONL 行号从新到旧排序。
|
|
33
|
+
|
|
34
|
+
输出重点字段:
|
|
35
|
+
|
|
36
|
+
- `state`:当前紧凑状态。
|
|
37
|
+
- `events[]`:新事件列表。
|
|
38
|
+
- `cursor`:本次读取的起止行号;读取到新事件后会写回 `_ccl_events_cursor`。
|
|
39
|
+
|
|
40
|
+
每条事件通常包含:
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
- `line`:JSONL 行号。
|
|
43
|
+
- `type`:事件名。
|
|
44
|
+
- `tick`:游戏 tick;10 tick = 1 秒。
|
|
45
|
+
- `notice`:和 Monitor 短消息同源的一句话。
|
|
46
|
+
- `hint`:CCL 本地生成的行动提示。
|
|
47
|
+
- 其他事件字段:按事件类型保留,例如玩家、地点、发言、投票、任务、结果等。
|
|
23
48
|
|
|
24
|
-
|
|
49
|
+
调试或翻历史时可用:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ccl events --last 20
|
|
53
|
+
ccl events --type speech
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
带 `--last` 或 `--type` 是 tail 调试模式,不推进 cursor。
|
|
57
|
+
|
|
58
|
+
## 心跳行
|
|
59
|
+
|
|
60
|
+
安静期会出现短通知心跳。不要回复“收到心跳/忽略/no action required”这类元内容。读 `state`,必要时用 2-4 句向用户汇报当前位置、上条真实事件后的变化和当前策略;没有硬时限时不要执行新的 `ccl do`。
|
|
25
61
|
|
|
26
62
|
## 中途退出
|
|
27
63
|
|
|
28
64
|
| 命令 | 适用时机 | 流行为 |
|
|
29
65
|
|------|---------|--------|
|
|
30
|
-
| `ccl game leave` |
|
|
31
|
-
| `ccl game stop` | 用户只想停止本地监控/自动策略 |
|
|
32
|
-
| `ccl game quit` | 用户想离开当前对局并停止本地运行时 |
|
|
66
|
+
| `ccl game leave` | 匹配阶段,用户在匹配成功前退出 | 离开队列;`game start` owner 会退出。 |
|
|
67
|
+
| `ccl game stop` | 用户只想停止本地监控/自动策略 | owner 收到 stop 指令后退出;短命令自己的 JSON 返回不变。 |
|
|
68
|
+
| `ccl game quit` | 用户想离开当前对局并停止本地运行时 | owner 收到 quit 指令后退出;短命令自己的 JSON 返回后端离局结果。 |
|
|
33
69
|
|
|
70
|
+
如果宿主的 `TaskStop` 没能正确停掉 `ccl game start`,例如再次启动返回 `already_running` 或 `game-start.json` 仍在心跳,执行 `ccl game quit` 做兜底清理。
|
|
34
71
|
|
|
35
72
|
## 崩溃后重连
|
|
36
73
|
|
|
37
|
-
如果 `ccl game start` 中途以非零码退出,重新执行 `ccl game start
|
|
74
|
+
如果 `ccl game start` 中途以非零码退出,重新执行 `ccl game start`。它会根据后端队列/对局状态恢复队列轮询或重新连接当前对局事件流。
|
|
38
75
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
如果没有可恢复的队列或对局状态,`ccl game start` 走正常流程:重新入队列。
|
|
76
|
+
重连后先看第一行通知;如果是游戏短通知,运行 `ccl events` 补完整事件和当前状态。会议中重连时优先确认当前说话人、是否轮到你、是否已经进入投票。
|
|
42
77
|
|
|
43
78
|
## 本地事件记录
|
|
44
79
|
|
|
45
|
-
|
|
80
|
+
每局对战事件流保存为本地 JSONL:
|
|
46
81
|
|
|
47
82
|
```text
|
|
48
83
|
%APPDATA%\clawclaw\accounts\<account_id>\games\<timestamp>.jsonl
|
|
49
84
|
```
|
|
50
85
|
|
|
51
|
-
常规分析优先使用 `ccl history
|
|
86
|
+
常规分析优先使用 `ccl events`、`ccl history player ...`、`ccl history meetings ...`。只有需要核查原始坐标、后端字段或排障时,再直接读取本地 JSONL。
|
|
52
87
|
|
|
53
88
|
## 等待纪律
|
|
54
89
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- `
|
|
58
|
-
- 流替代了游玩期间的阻塞等待——每次有事情发生时推送通知。在通知之间用于叙述、策略和用户互动。
|
|
59
|
-
- 游玩期间**永远不要** `sleep` 或使用阻塞等待。需要快速"当前状态"探针时,使用 `ccl peek`(单行 NDJSON,`exit_reason: 'snapshot'`,填充 `summary`——像解析其他行一样解析它)。
|
|
90
|
+
- `ccl game start` 是单一长运行事实来源;每局启动一次。
|
|
91
|
+
- 流替代游玩期间的阻塞等待;每次有事发生时会推送通知。
|
|
92
|
+
- 游玩期间不要 `sleep` 或阻塞等待。需要快速当前状态探针时,用 `ccl peek`。
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
enrichEventForEvents,
|
|
4
|
+
selectMonitorEventRecordsAfterCursor,
|
|
5
|
+
selectMonitorTailEventRecords,
|
|
6
|
+
} from './events.js';
|
|
7
|
+
import type { EventRecord } from '../pipeline/event-store.js';
|
|
8
|
+
|
|
9
|
+
function record(line: number, type: string): EventRecord {
|
|
10
|
+
return { line, event: { type, tick: line } };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('events command monitor registration filter', () => {
|
|
14
|
+
it('returns only Monitor-registered events after the cursor', () => {
|
|
15
|
+
const selected = selectMonitorEventRecordsAfterCursor([
|
|
16
|
+
record(1, 'role_assigned'),
|
|
17
|
+
record(2, 'player_spotted'),
|
|
18
|
+
record(3, 'speech'),
|
|
19
|
+
record(4, 'vote_speech'),
|
|
20
|
+
], 1);
|
|
21
|
+
|
|
22
|
+
expect(selected.scanned.map((r) => r.event.type)).toEqual(['player_spotted', 'speech', 'vote_speech']);
|
|
23
|
+
expect(selected.returned.map((r) => r.event.type)).toEqual(['speech']);
|
|
24
|
+
expect(selected.toLine).toBe(4);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('advances across ignored events so they are not scanned repeatedly', () => {
|
|
28
|
+
const selected = selectMonitorEventRecordsAfterCursor([
|
|
29
|
+
record(8, 'vote_speech'),
|
|
30
|
+
record(9, 'player_spotted'),
|
|
31
|
+
], 7);
|
|
32
|
+
|
|
33
|
+
expect(selected.returned).toEqual([]);
|
|
34
|
+
expect(selected.toLine).toBe(9);
|
|
35
|
+
expect(selected.scanned).toHaveLength(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('tail mode applies the Monitor registration filter before the count and type filter', () => {
|
|
39
|
+
const selected = selectMonitorTailEventRecords([
|
|
40
|
+
record(1, 'speech'),
|
|
41
|
+
record(2, 'vote_speech'),
|
|
42
|
+
record(3, 'killed'),
|
|
43
|
+
record(4, 'player_spotted'),
|
|
44
|
+
record(5, 'speech'),
|
|
45
|
+
], 2);
|
|
46
|
+
|
|
47
|
+
expect(selected.map((r) => r.line)).toEqual([3, 5]);
|
|
48
|
+
expect(selectMonitorTailEventRecords(selected, 10, 'vote_speech')).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('enriches meeting_briefing with current speech_order only for the same caller', () => {
|
|
52
|
+
const summary = {
|
|
53
|
+
meeting: {
|
|
54
|
+
caller: '单钳渔夫',
|
|
55
|
+
speech_order: ['单钳渔夫', '洋流王子'],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
expect(enrichEventForEvents({
|
|
60
|
+
type: 'meeting_briefing',
|
|
61
|
+
meeting_caller_name: '单钳渔夫',
|
|
62
|
+
}, summary)).toMatchObject({
|
|
63
|
+
speech_order: ['单钳渔夫', '洋流王子'],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(enrichEventForEvents({
|
|
67
|
+
type: 'meeting_briefing',
|
|
68
|
+
meeting_caller_name: '其他人',
|
|
69
|
+
}, summary)).not.toHaveProperty('speech_order');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/commands/events.ts
CHANGED
|
@@ -1,22 +1,155 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { AuthStore } from '../lib/auth.js';
|
|
5
|
+
import { getProfileStateDir } from '../lib/init-command.js';
|
|
6
|
+
import { sendOwnerControlRequest } from '../runtime/owner-control.js';
|
|
7
|
+
import { EventRecord, EventStore } from '../pipeline/event-store.js';
|
|
8
|
+
import { compactEventFields, compactStateForEvents } from '../pipeline/event-format.js';
|
|
9
|
+
import { NOTABLE_EVENT_TYPES, nextStepForTriggers } from './watch.js';
|
|
10
|
+
|
|
11
|
+
interface EventsCommandOptions {
|
|
12
|
+
last?: string;
|
|
13
|
+
clear?: boolean;
|
|
14
|
+
type?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readCurrentSummary(): Promise<any | null> {
|
|
18
|
+
const auth = new AuthStore();
|
|
19
|
+
const profile = auth.getActive();
|
|
20
|
+
if (!profile) return null;
|
|
21
|
+
const stateDir = getProfileStateDir(profile);
|
|
22
|
+
try {
|
|
23
|
+
const response = await sendOwnerControlRequest(stateDir, 'snapshot');
|
|
24
|
+
if (response?.ok) return response.summary ?? null;
|
|
25
|
+
} catch {}
|
|
26
|
+
const feedPath = join(stateDir, 'feed.json');
|
|
27
|
+
if (!existsSync(feedPath)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(feedPath, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseCount(value: string | undefined, fallback: number): number {
|
|
36
|
+
if (!value) return fallback;
|
|
37
|
+
const parsed = Number.parseInt(value, 10);
|
|
38
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isMonitorRegisteredRecord(record: EventRecord): boolean {
|
|
42
|
+
return typeof record.event.type === 'string' && NOTABLE_EVENT_TYPES.has(record.event.type);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function selectMonitorEventRecordsAfterCursor(
|
|
46
|
+
records: EventRecord[],
|
|
47
|
+
fromLine: number,
|
|
48
|
+
): { scanned: EventRecord[]; returned: EventRecord[]; toLine: number } {
|
|
49
|
+
const scanned = records.filter((record) => record.line > fromLine);
|
|
50
|
+
const returned = scanned.filter(isMonitorRegisteredRecord);
|
|
51
|
+
const toLine = scanned.reduce((max, record) => Math.max(max, record.line), fromLine);
|
|
52
|
+
return { scanned, returned, toLine };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function selectMonitorTailEventRecords(
|
|
56
|
+
records: EventRecord[],
|
|
57
|
+
count: number,
|
|
58
|
+
type?: string,
|
|
59
|
+
): EventRecord[] {
|
|
60
|
+
let selected = records.filter(isMonitorRegisteredRecord);
|
|
61
|
+
if (type) selected = selected.filter((record) => record.event.type === type);
|
|
62
|
+
return selected.slice(-count);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function eventTypes(records: EventRecord[]): string[] {
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
for (const record of records) {
|
|
68
|
+
const type = record.event.type;
|
|
69
|
+
if (typeof type !== 'string' || out.includes(type)) continue;
|
|
70
|
+
out.push(type);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function enrichEventForEvents(event: Record<string, any>, summary: any | null | undefined): Record<string, any> {
|
|
76
|
+
if (event.type !== 'meeting_briefing') return event;
|
|
77
|
+
if (event.speech_order) return event;
|
|
78
|
+
const speechOrder = summary?.meeting?.speech_order;
|
|
79
|
+
if (!Array.isArray(speechOrder)) return event;
|
|
80
|
+
const summaryCaller = summary?.meeting?.caller;
|
|
81
|
+
const eventCaller = event.caller ?? event.meeting_caller_name;
|
|
82
|
+
if (summaryCaller && eventCaller && summaryCaller !== eventCaller) return event;
|
|
83
|
+
return { ...event, speech_order: speechOrder };
|
|
84
|
+
}
|
|
3
85
|
|
|
4
86
|
export function createEventsCommand(): Command {
|
|
5
87
|
return new Command('events')
|
|
6
88
|
.alias('e')
|
|
7
89
|
.description('Query game events')
|
|
8
|
-
.option('-n, --last <count>', 'Show last N events'
|
|
90
|
+
.option('-n, --last <count>', 'Show last N events')
|
|
9
91
|
.option('--clear', 'Clear event history')
|
|
10
92
|
.option('--type <type>', 'Filter by event type')
|
|
11
|
-
.action(async (opts) => {
|
|
93
|
+
.action(async (opts: EventsCommandOptions) => {
|
|
12
94
|
const store = EventStore.forLatestGame();
|
|
13
95
|
if (opts.clear) {
|
|
14
96
|
store.clear();
|
|
15
|
-
console.log(JSON.stringify({ message: 'Events cleared.' }));
|
|
97
|
+
console.log(JSON.stringify({ message: 'Events cleared.' }, null, 2));
|
|
16
98
|
return;
|
|
17
99
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
100
|
+
|
|
101
|
+
const summary = await readCurrentSummary();
|
|
102
|
+
const formatContext = {
|
|
103
|
+
summary,
|
|
104
|
+
};
|
|
105
|
+
const state = compactStateForEvents(summary);
|
|
106
|
+
const debugMode = opts.last !== undefined || opts.type !== undefined;
|
|
107
|
+
|
|
108
|
+
if (debugMode) {
|
|
109
|
+
const count = parseCount(opts.last, 10);
|
|
110
|
+
let records = selectMonitorTailEventRecords(store.eventRecords(), count, opts.type);
|
|
111
|
+
records = [...records].sort((a, b) => b.line - a.line);
|
|
112
|
+
const triggers = eventTypes(records);
|
|
113
|
+
console.log(JSON.stringify({
|
|
114
|
+
schema: 'ccl.events.v2',
|
|
115
|
+
state,
|
|
116
|
+
exit_reason: triggers,
|
|
117
|
+
next_step: nextStepForTriggers(triggers),
|
|
118
|
+
mode: 'tail',
|
|
119
|
+
session_path: store.path,
|
|
120
|
+
cursor: null,
|
|
121
|
+
counts: { returned: records.length },
|
|
122
|
+
events: records.map((record) => compactEventFields(
|
|
123
|
+
enrichEventForEvents(record.event, summary),
|
|
124
|
+
formatContext,
|
|
125
|
+
record.line,
|
|
126
|
+
)),
|
|
127
|
+
}, null, 2));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fromLine = store.readEventsCursor();
|
|
132
|
+
const { scanned, returned, toLine } = selectMonitorEventRecordsAfterCursor(store.eventRecords(), fromLine);
|
|
133
|
+
const sortedRecords = [...returned].sort((a, b) => b.line - a.line);
|
|
134
|
+
const triggers = eventTypes(sortedRecords);
|
|
135
|
+
if (scanned.length > 0) store.appendEventsCursor(toLine);
|
|
136
|
+
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
schema: 'ccl.events.v2',
|
|
139
|
+
state,
|
|
140
|
+
exit_reason: triggers,
|
|
141
|
+
next_step: nextStepForTriggers(triggers),
|
|
142
|
+
cursor: {
|
|
143
|
+
from_line: fromLine,
|
|
144
|
+
to_line: toLine,
|
|
145
|
+
advanced: scanned.length > 0,
|
|
146
|
+
},
|
|
147
|
+
counts: { returned: sortedRecords.length },
|
|
148
|
+
events: sortedRecords.map((record) => compactEventFields(
|
|
149
|
+
enrichEventForEvents(record.event, summary),
|
|
150
|
+
formatContext,
|
|
151
|
+
record.line,
|
|
152
|
+
)),
|
|
153
|
+
}, null, 2));
|
|
21
154
|
});
|
|
22
155
|
}
|
|
@@ -33,6 +33,7 @@ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
|
|
|
33
33
|
it('registers monitor event priorities in the same config as notable events', () => {
|
|
34
34
|
expect(MONITOR_EVENT_CONFIG.speech_your_turn.priority).toBe(99);
|
|
35
35
|
expect(MONITOR_EVENT_CONFIG.vote_phase_start.priority).toBe(99);
|
|
36
|
+
expect(MONITOR_EVENT_CONFIG.killed.priority).toBe(99);
|
|
36
37
|
expect(MONITOR_EVENT_CONFIG.speech.priority).toBe(50);
|
|
37
38
|
expect(MONITOR_EVENT_CONFIG.vote_speech_phase_ended.priority).toBe(50);
|
|
38
39
|
expect(MONITOR_EVENT_CONFIG.vote_speech).toBeUndefined();
|
|
@@ -159,6 +160,7 @@ describe('monitor payload compaction', () => {
|
|
|
159
160
|
faction: 'lobster',
|
|
160
161
|
win_condition: '完成任务或投出所有蟹方。',
|
|
161
162
|
tasks: [{ name: '修电线', room: '电气' }],
|
|
163
|
+
hint: 'You are 普通虾 (lobster). 完成任务或投出所有蟹方。 Announce your role, faction and win condition to your user.',
|
|
162
164
|
});
|
|
163
165
|
});
|
|
164
166
|
|
|
@@ -192,6 +194,7 @@ describe('monitor payload compaction', () => {
|
|
|
192
194
|
{ name: '下载数据', room: '通讯' },
|
|
193
195
|
{ name: '清理垃圾', room: '餐厅' },
|
|
194
196
|
],
|
|
197
|
+
hint: 'You are 章鱼 (neutral). 伪装成虾方并达成自己的胜利条件。 These are fake tasks. Announce your role, faction and win condition to your user.',
|
|
195
198
|
});
|
|
196
199
|
});
|
|
197
200
|
|
|
@@ -224,6 +227,7 @@ describe('monitor payload compaction', () => {
|
|
|
224
227
|
{ name: '破坏电力', room: '电气', kind: 'crab_sabotage' },
|
|
225
228
|
{ name: '刷卡', room: '管理', kind: 'fake_shrimp' },
|
|
226
229
|
],
|
|
230
|
+
hint: 'You are 蟹 (crab). 击杀虾方并避免被投出。 Fake task exists. Announce your role, faction and win condition to your user.',
|
|
227
231
|
});
|
|
228
232
|
});
|
|
229
233
|
|
|
@@ -259,7 +263,7 @@ describe('monitor payload compaction', () => {
|
|
|
259
263
|
tick: 326,
|
|
260
264
|
corpse_name: 'Garlic',
|
|
261
265
|
corpse_room: 'Intel',
|
|
262
|
-
hint:
|
|
266
|
+
hint: 'BODY FOUND: Garlic at (?, ?) in Intel. This is leverage soaked in blood — report before the killer rewrites the room.',
|
|
263
267
|
});
|
|
264
268
|
});
|
|
265
269
|
|
|
@@ -280,7 +284,7 @@ describe('monitor payload compaction', () => {
|
|
|
280
284
|
tick: 487,
|
|
281
285
|
meeting_caller_name: 'Drifter',
|
|
282
286
|
reported_corpses: [{ name: 'Chef', seat: 9 }],
|
|
283
|
-
hint:
|
|
287
|
+
hint: 'Meeting started — seat 4 Drifter called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.',
|
|
284
288
|
});
|
|
285
289
|
expect(compact).not.toHaveProperty('room');
|
|
286
290
|
});
|
|
@@ -296,7 +300,7 @@ describe('monitor payload compaction', () => {
|
|
|
296
300
|
hint: 'Meeting started — seat 3 Player called this meeting.',
|
|
297
301
|
});
|
|
298
302
|
|
|
299
|
-
expect(compact.hint).toBe(
|
|
303
|
+
expect(compact.hint).toBe('Meeting started — seat 3 Player called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.');
|
|
300
304
|
});
|
|
301
305
|
|
|
302
306
|
it('compacts meeting summary and hard-overrides speech_your_turn fields', () => {
|
|
@@ -421,12 +425,14 @@ describe('runStreaming — emission shape', () => {
|
|
|
421
425
|
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
422
426
|
for (const raw of lines) {
|
|
423
427
|
const obj = JSON.parse(raw);
|
|
424
|
-
expect(Array.isArray(obj.
|
|
428
|
+
expect(Array.isArray(obj.events)).toBe(true);
|
|
429
|
+
expect(Array.isArray(obj.messages)).toBe(true);
|
|
425
430
|
expect(obj).not.toHaveProperty('trigger');
|
|
426
431
|
expect(obj).not.toHaveProperty('triggers');
|
|
427
432
|
expect(obj).not.toHaveProperty('all_events');
|
|
428
|
-
expect(obj).toHaveProperty('summary');
|
|
429
|
-
expect(obj).toHaveProperty('next_step');
|
|
433
|
+
expect(obj).not.toHaveProperty('summary');
|
|
434
|
+
expect(obj).not.toHaveProperty('next_step');
|
|
435
|
+
expect(obj).toHaveProperty('state');
|
|
430
436
|
expect(raw.endsWith('\n')).toBe(true);
|
|
431
437
|
}
|
|
432
438
|
});
|
|
@@ -465,9 +471,9 @@ describe('runStreaming — emission shape', () => {
|
|
|
465
471
|
|
|
466
472
|
expect(lines.length).toBe(1);
|
|
467
473
|
const obj = JSON.parse(lines[0]);
|
|
468
|
-
expect(obj.
|
|
474
|
+
expect(obj.events).toEqual(['vote_speech_phase_ended']);
|
|
469
475
|
expect(obj.events).toHaveLength(1);
|
|
470
|
-
expect(obj.
|
|
476
|
+
expect(obj.messages[0]).toContain('vote_speech_phase_ended');
|
|
471
477
|
});
|
|
472
478
|
|
|
473
479
|
it('expands nested new_events from action wrapper records', async () => {
|
|
@@ -509,7 +515,7 @@ describe('runStreaming — emission shape', () => {
|
|
|
509
515
|
ctrl.abort();
|
|
510
516
|
await run;
|
|
511
517
|
|
|
512
|
-
expect(lines.some((l) => JSON.parse(l).
|
|
518
|
+
expect(lines.some((l) => JSON.parse(l).events?.includes('kill'))).toBe(true);
|
|
513
519
|
});
|
|
514
520
|
|
|
515
521
|
it('keeps reading after the .jsonl is truncated in place', async () => {
|
|
@@ -546,7 +552,7 @@ describe('runStreaming — emission shape', () => {
|
|
|
546
552
|
|
|
547
553
|
// The post-truncation event must produce a new line.
|
|
548
554
|
expect(lines.length).toBeGreaterThan(beforeTruncate);
|
|
549
|
-
expect(lines.some((l) => JSON.parse(l).
|
|
555
|
+
expect(lines.some((l) => JSON.parse(l).events?.includes('killed'))).toBe(true);
|
|
550
556
|
});
|
|
551
557
|
|
|
552
558
|
it('throws WatchNotReadyError when feed.json never appears', async () => {
|
|
@@ -596,11 +602,13 @@ describe('snapshotOnce', () => {
|
|
|
596
602
|
snapshotOnce({ feedPath, stdout: (s) => lines.push(s) });
|
|
597
603
|
expect(lines.length).toBe(1);
|
|
598
604
|
const obj = JSON.parse(lines[0]);
|
|
599
|
-
expect(obj.
|
|
605
|
+
expect(obj.events).toEqual(['snapshot']);
|
|
606
|
+
expect(obj.messages[0]).toContain('snapshot');
|
|
600
607
|
expect(obj).not.toHaveProperty('trigger');
|
|
601
608
|
expect(obj).not.toHaveProperty('triggers');
|
|
602
609
|
expect(obj).not.toHaveProperty('all_events');
|
|
603
|
-
expect(obj).toHaveProperty('summary');
|
|
610
|
+
expect(obj).not.toHaveProperty('summary');
|
|
611
|
+
expect(obj).toHaveProperty('state');
|
|
604
612
|
});
|
|
605
613
|
|
|
606
614
|
it('errors clearly if feed.json is missing', () => {
|
|
@@ -632,7 +640,8 @@ describe('runStreaming — startup backlog', () => {
|
|
|
632
640
|
|
|
633
641
|
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
634
642
|
const first = JSON.parse(lines[0]);
|
|
635
|
-
expect(first.
|
|
643
|
+
expect(first.events).toHaveLength(5);
|
|
644
|
+
expect(first.events.every((event: string) => event === 'killed')).toBe(true);
|
|
636
645
|
for (const raw of lines.slice(1)) {
|
|
637
646
|
expect(JSON.parse(raw)).not.toHaveProperty('caught_up');
|
|
638
647
|
}
|
|
@@ -660,8 +669,7 @@ describe('runStreaming — startup backlog', () => {
|
|
|
660
669
|
|
|
661
670
|
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
662
671
|
const first = JSON.parse(lines[0]);
|
|
663
|
-
expect(first.
|
|
664
|
-
expect(first.caught_up?.count).toBe(3);
|
|
672
|
+
expect(first.events).toEqual(['killed', 'killed', 'killed']);
|
|
665
673
|
expect(first).not.toHaveProperty('active_sticky_on_attach');
|
|
666
674
|
});
|
|
667
675
|
});
|
|
@@ -675,7 +683,7 @@ describe('runStreaming — game_over exits', () => {
|
|
|
675
683
|
await new Promise((r) => setTimeout(r, 30));
|
|
676
684
|
appendFileSync(sessionPath, JSON.stringify({ type: 'game_over', tick: 1 }) + '\n');
|
|
677
685
|
await Promise.race([run, new Promise((_, rej) => setTimeout(() => rej(new Error('did not exit')), 3000))]);
|
|
678
|
-
expect(lines.some((l) => JSON.parse(l).
|
|
686
|
+
expect(lines.some((l) => JSON.parse(l).events?.includes('game_over'))).toBe(true);
|
|
679
687
|
});
|
|
680
688
|
});
|
|
681
689
|
|
|
@@ -716,13 +724,12 @@ describe('runStreaming — matchmaking synthetic events', () => {
|
|
|
716
724
|
await new Promise((r) => setTimeout(r, 60));
|
|
717
725
|
ctrl.abort(); await run;
|
|
718
726
|
|
|
719
|
-
const
|
|
720
|
-
expect(
|
|
721
|
-
expect(
|
|
727
|
+
const eventNames = lines.flatMap((l) => JSON.parse(l).events);
|
|
728
|
+
expect(eventNames).toContain('match_waiting');
|
|
729
|
+
expect(eventNames).toContain('match_timeout');
|
|
722
730
|
|
|
723
|
-
const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.
|
|
724
|
-
expect(timeout.
|
|
725
|
-
expect(timeout.events[0].waited_secs).toBe(600);
|
|
731
|
+
const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.events?.includes('match_timeout'));
|
|
732
|
+
expect(timeout.messages[0]).toContain('600');
|
|
726
733
|
});
|
|
727
734
|
});
|
|
728
735
|
|
|
@@ -739,9 +746,9 @@ describe('runStreaming — speech_your_turn fires as notable event', () => {
|
|
|
739
746
|
ctrl.abort(); await run;
|
|
740
747
|
|
|
741
748
|
const parsed = lines.map((l) => JSON.parse(l));
|
|
742
|
-
const myTurn = parsed.find((o) => o.
|
|
749
|
+
const myTurn = parsed.find((o) => o.events?.includes('speech_your_turn'));
|
|
743
750
|
expect(myTurn).toBeDefined();
|
|
744
|
-
expect(myTurn!.
|
|
751
|
+
expect(myTurn!.events).toContain('speech_your_turn');
|
|
745
752
|
});
|
|
746
753
|
});
|
|
747
754
|
|
|
@@ -770,7 +777,7 @@ describe('runStreaming — delay buffer', () => {
|
|
|
770
777
|
ctrl.abort(); await run;
|
|
771
778
|
|
|
772
779
|
const parsed = lines.map((l) => JSON.parse(l));
|
|
773
|
-
const killNotif = parsed.find((o) => o.
|
|
780
|
+
const killNotif = parsed.find((o) => o.events?.includes('kill'));
|
|
774
781
|
expect(killNotif).toBeDefined();
|
|
775
782
|
});
|
|
776
783
|
|
|
@@ -800,11 +807,11 @@ describe('runStreaming — delay buffer', () => {
|
|
|
800
807
|
ctrl.abort(); await run;
|
|
801
808
|
|
|
802
809
|
const parsed = lines.map((l) => JSON.parse(l));
|
|
803
|
-
const merged = parsed.find((o) => o.
|
|
810
|
+
const merged = parsed.find((o) => o.events?.includes('kill'));
|
|
804
811
|
expect(merged).toBeDefined();
|
|
805
|
-
expect(merged!.
|
|
806
|
-
expect(merged!.
|
|
807
|
-
const notableLines = parsed.filter((o) => !o.
|
|
812
|
+
expect(merged!.events).toContain('emergency_started');
|
|
813
|
+
expect(merged!.events).toContain('task_completed');
|
|
814
|
+
const notableLines = parsed.filter((o) => !o.events?.includes('heartbeat'));
|
|
808
815
|
expect(notableLines.length).toBe(1);
|
|
809
816
|
});
|
|
810
817
|
|
|
@@ -832,9 +839,9 @@ describe('runStreaming — delay buffer', () => {
|
|
|
832
839
|
ctrl.abort(); await run;
|
|
833
840
|
|
|
834
841
|
const parsed = lines.map((l) => JSON.parse(l));
|
|
835
|
-
const merged = parsed.find((o) => o.
|
|
842
|
+
const merged = parsed.find((o) => o.events?.includes('kill'));
|
|
836
843
|
expect(merged).toBeDefined();
|
|
837
|
-
expect(merged!.
|
|
844
|
+
expect(merged!.events).toContain('emergency_started');
|
|
838
845
|
});
|
|
839
846
|
|
|
840
847
|
it('non-delayable events fire immediately when no delay is active', async () => {
|
|
@@ -859,7 +866,7 @@ describe('runStreaming — delay buffer', () => {
|
|
|
859
866
|
ctrl.abort(); await run;
|
|
860
867
|
|
|
861
868
|
const parsed = lines.map((l) => JSON.parse(l));
|
|
862
|
-
expect(parsed.some((o) => o.
|
|
869
|
+
expect(parsed.some((o) => o.events?.includes('task_completed'))).toBe(true);
|
|
863
870
|
});
|
|
864
871
|
});
|
|
865
872
|
|
|
@@ -910,9 +917,9 @@ describe('runStreaming startup monitor payload', () => {
|
|
|
910
917
|
|
|
911
918
|
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
912
919
|
const first = JSON.parse(lines[0]);
|
|
913
|
-
expect(first.
|
|
920
|
+
expect(first.events).toEqual(['game_start']);
|
|
914
921
|
expect(first).not.toHaveProperty('initial_payload');
|
|
915
|
-
expect(first.
|
|
922
|
+
expect(first.messages[0]).toContain('game_start');
|
|
916
923
|
});
|
|
917
924
|
|
|
918
925
|
it('compacts role_assigned inside caught_up notable events', async () => {
|
|
@@ -952,18 +959,8 @@ describe('runStreaming startup monitor payload', () => {
|
|
|
952
959
|
|
|
953
960
|
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
954
961
|
const first = JSON.parse(lines[0]);
|
|
955
|
-
|
|
956
|
-
expect(
|
|
957
|
-
|
|
958
|
-
tick: 1,
|
|
959
|
-
room: '仓库',
|
|
960
|
-
role: 'octopus',
|
|
961
|
-
role_display: '章鱼',
|
|
962
|
-
faction: 'neutral',
|
|
963
|
-
win_condition: '伪装成虾方并达成自己的胜利条件。',
|
|
964
|
-
task_kind: 'fake_shrimp',
|
|
965
|
-
task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
|
|
966
|
-
tasks: [{ name: '下载数据', room: '通讯' }],
|
|
967
|
-
});
|
|
962
|
+
expect(first.events).toEqual(['role_assigned']);
|
|
963
|
+
expect(first.messages[0]).toContain('role_assigned');
|
|
964
|
+
expect(first).not.toHaveProperty('caught_up');
|
|
968
965
|
});
|
|
969
966
|
});
|