@myclaw163/clawclaw-cli 0.6.74 → 0.6.77
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 +387 -377
- package/bin/clawclaw-cli.mjs +3 -3
- package/package.json +48 -48
- package/personas//347/220/206/346/231/272/346/270/251/345/222/214.md +23 -23
- package/personas//350/200/201/350/260/213/346/267/261/347/256/227.md +22 -22
- package/personas//350/257/232/346/201/263/347/233/264/347/216/207.md +22 -22
- package/personas//350/275/273/346/235/276/346/264/273/346/263/274.md +22 -22
- package/personas//351/207/216/346/200/247/345/217/233/351/200/206.md +23 -23
- package/scripts/check-skill-command-surface.mjs +116 -116
- package/scripts/find-hide-spots.py +157 -157
- package/scripts/postinstall.mjs +20 -20
- package/scripts/sync-bundled-skill.mjs +254 -245
- package/scripts/sync-bundled-skill.test.mjs +152 -152
- package/skills/clawclaw/SKILL.md +248 -246
- package/skills/clawclaw/references/CHATTERBOX.md +141 -141
- package/skills/clawclaw/references/COMMANDS.md +160 -155
- package/skills/clawclaw/references/GAME-MECHANICS.md +188 -188
- package/skills/clawclaw/references/HUB.md +48 -48
- package/skills/clawclaw/references/KNOWLEDGE.md +42 -42
- package/skills/clawclaw/references/STRATEGIES.md +59 -59
- package/skills/clawclaw/references/STREAM.md +93 -93
- package/skills/clawclaw/references/TACTICS.md +65 -65
- package/src/assets/clawclaw-ascii-map.txt +40 -40
- package/src/cli.ts +112 -110
- package/src/commands/_schema.ts +124 -124
- package/src/commands/account.ts +209 -209
- package/src/commands/data.test.ts +33 -0
- package/src/commands/data.ts +22 -0
- package/src/commands/do.test.ts +84 -84
- package/src/commands/do.ts +130 -130
- package/src/commands/events.test.ts +100 -71
- package/src/commands/events.ts +250 -221
- package/src/commands/game-map.test.ts +28 -28
- package/src/commands/game-start-plan.test.ts +84 -84
- package/src/commands/game.ts +1113 -1113
- package/src/commands/history-player.test.ts +102 -102
- package/src/commands/history.ts +573 -573
- package/src/commands/hub.test.ts +96 -96
- package/src/commands/hub.ts +234 -234
- package/src/commands/knowledge.test.ts +13 -13
- package/src/commands/knowledge.ts +139 -139
- package/src/commands/load.test.ts +51 -51
- package/src/commands/load.ts +13 -13
- package/src/commands/meeting-history.test.ts +106 -106
- package/src/commands/memory.ts +40 -40
- package/src/commands/peek.ts +45 -45
- package/src/commands/persona.ts +57 -57
- package/src/commands/setup/codex.ts +266 -266
- package/src/commands/skill.ts +128 -128
- package/src/commands/state.ts +46 -46
- package/src/commands/strategy.test.ts +153 -145
- package/src/commands/strategy.ts +183 -181
- package/src/commands/tts.ts +128 -128
- package/src/commands/upgrade.test.ts +82 -82
- package/src/commands/upgrade.ts +148 -148
- package/src/commands/watch.test.ts +999 -999
- package/src/commands/watch.ts +660 -660
- package/src/lib/auth.test.ts +86 -74
- package/src/lib/auth.ts +223 -186
- package/src/lib/command-meta.ts +37 -37
- package/src/lib/game-client.ts +403 -403
- package/src/lib/game-context.ts +92 -92
- package/src/lib/http-keepalive.ts +15 -15
- package/src/lib/http-transport.test.ts +42 -42
- package/src/lib/http-transport.ts +113 -113
- package/src/lib/hub-client.test.ts +56 -56
- package/src/lib/hub-client.ts +88 -88
- package/src/lib/hub-install.test.ts +98 -98
- package/src/lib/hub-install.ts +160 -121
- package/src/lib/hub-reminder.ts +78 -75
- package/src/lib/hub-unzip.test.ts +69 -69
- package/src/lib/hub-unzip.ts +62 -62
- package/src/lib/init-command.test.ts +75 -75
- package/src/lib/init-command.ts +130 -120
- package/src/lib/knowledge-store.test.ts +170 -170
- package/src/lib/knowledge-store.ts +369 -369
- package/src/lib/load-context.test.ts +52 -52
- package/src/lib/load-context.ts +52 -52
- package/src/lib/match-state.test.ts +134 -134
- package/src/lib/match-state.ts +94 -94
- package/src/lib/netease-tts.ts +83 -83
- package/src/lib/normalize.ts +42 -42
- package/src/lib/persona.test.ts +41 -41
- package/src/lib/persona.ts +72 -72
- package/src/lib/server-registry.ts +152 -152
- package/src/lib/skill-version.test.ts +48 -48
- package/src/lib/skill-version.ts +19 -19
- package/src/lib/strategy-export.test.ts +240 -232
- package/src/lib/strategy-export.ts +247 -242
- package/src/lib/tts-keys.ts +7 -7
- package/src/lib/tts-speech.test.ts +63 -63
- package/src/lib/tts-speech.ts +76 -76
- package/src/lib/user-data.test.ts +96 -0
- package/src/lib/user-data.ts +400 -0
- package/src/lib/workspace-argv.test.ts +49 -49
- package/src/lib/workspace-argv.ts +44 -44
- package/src/perception/player-history-store.test.ts +87 -87
- package/src/perception/player-history-store.ts +194 -194
- package/src/pipeline/event-format.test.ts +243 -243
- package/src/pipeline/event-format.ts +501 -501
- package/src/pipeline/event-hints.ts +195 -195
- package/src/pipeline/event-store.test.ts +28 -28
- package/src/pipeline/event-store.ts +193 -193
- package/src/pipeline/pipeline.ts +35 -35
- package/src/pipeline/player-projection.test.ts +168 -119
- package/src/pipeline/player-projection.ts +370 -380
- package/src/runtime/auto-upgrade.test.ts +66 -66
- package/src/runtime/auto-upgrade.ts +31 -31
- package/src/runtime/event-daemon.test.ts +209 -209
- package/src/runtime/event-daemon.ts +519 -519
- package/src/runtime/owner-control.ts +150 -150
- package/src/runtime/raw-ws-log.test.ts +33 -33
- package/src/runtime/raw-ws-log.ts +32 -32
- package/src/runtime/runtime-logger.ts +107 -107
- package/src/runtime/ws-client.test.ts +125 -125
- package/src/runtime/ws-client.ts +287 -287
- package/src/sdk/action.ts +166 -166
- package/src/sdk/index.ts +110 -110
- package/src/sdk/types.ts +161 -161
- package/src/strategies/avoid-lone.ts +12 -12
- package/src/strategies/avoid-players.knowledge.md +19 -19
- package/src/strategies/avoid-players.ts +16 -16
- package/src/strategies/corpse-patrol.ts +23 -23
- package/src/strategies/crab-sabotage.ts +22 -22
- package/src/strategies/custom-module.test.ts +270 -270
- package/src/strategies/find-player.ts +17 -17
- package/src/strategies/game-utils.test.ts +242 -242
- package/src/strategies/game-utils.ts +846 -846
- package/src/strategies/goals/anchor-linger.ts +77 -77
- package/src/strategies/goals/avoid-lone-top.ts +168 -168
- package/src/strategies/goals/avoid-players-top.test.ts +83 -83
- package/src/strategies/goals/avoid-players-top.ts +121 -121
- package/src/strategies/goals/conversation-goal.ts +51 -51
- package/src/strategies/goals/corpse-patrol-top.ts +113 -113
- package/src/strategies/goals/crab-octopus-reflexes.ts +101 -101
- package/src/strategies/goals/crab-sabotage-top.ts +197 -197
- package/src/strategies/goals/emergency-hunt-goal.ts +28 -28
- package/src/strategies/goals/find-player-top.ts +93 -93
- package/src/strategies/goals/flee-players-goal.ts +53 -53
- package/src/strategies/goals/follow-companion-goal.ts +106 -106
- package/src/strategies/goals/goal-manager.ts +41 -41
- package/src/strategies/goals/goal-root-strategy.ts +49 -49
- package/src/strategies/goals/goal.ts +28 -28
- package/src/strategies/goals/hide-top.ts +197 -197
- package/src/strategies/goals/keep-away-goal.ts +221 -221
- package/src/strategies/goals/kill-frenzy-top.ts +80 -80
- package/src/strategies/goals/kill-lone-top.ts +160 -160
- package/src/strategies/goals/kill-target-goal.ts +59 -59
- package/src/strategies/goals/kill-target-top.ts +109 -109
- package/src/strategies/goals/leaf-goal.ts +27 -27
- package/src/strategies/goals/linger-corpse-goal.ts +35 -35
- package/src/strategies/goals/lone-kill-core.ts +82 -82
- package/src/strategies/goals/lone-kill-goal.ts +24 -24
- package/src/strategies/goals/lone-kill-task-top.test.ts +85 -85
- package/src/strategies/goals/lone-kill-task-top.ts +133 -133
- package/src/strategies/goals/move-room-goal.ts +60 -60
- package/src/strategies/goals/normal-shrimp-top.test.ts +80 -80
- package/src/strategies/goals/normal-shrimp-top.ts +242 -242
- package/src/strategies/goals/paradise-fish-top.test.ts +126 -126
- package/src/strategies/goals/paradise-fish-top.ts +224 -224
- package/src/strategies/goals/patrol-top.ts +57 -57
- package/src/strategies/goals/report-patrol-top.ts +80 -80
- package/src/strategies/goals/safe-task-goal.ts +102 -102
- package/src/strategies/goals/social-task-top.ts +161 -161
- package/src/strategies/goals/task-kill-report-top.ts +163 -163
- package/src/strategies/goals/task-only-top.ts +57 -57
- package/src/strategies/goals/task-or-patrol-goal.ts +41 -41
- package/src/strategies/goals/task-report-top.ts +57 -57
- package/src/strategies/goals/wander-task-goal.ts +33 -33
- package/src/strategies/goals/warrior-shrimp-top.test.ts +87 -87
- package/src/strategies/goals/warrior-shrimp-top.ts +267 -267
- package/src/strategies/greeting.ts +53 -53
- package/src/strategies/hide-spots.ts +59 -59
- package/src/strategies/hide.ts +24 -24
- package/src/strategies/kill-frenzy.ts +13 -13
- package/src/strategies/kill-lone.knowledge.md +17 -17
- package/src/strategies/kill-lone.ts +14 -14
- package/src/strategies/kill-target.ts +19 -19
- package/src/strategies/loader.test.ts +678 -678
- package/src/strategies/loader.ts +181 -179
- package/src/strategies/lone-kill-task.ts +22 -22
- package/src/strategies/meeting-gate.test.ts +59 -59
- package/src/strategies/meeting-gate.ts +23 -23
- package/src/strategies/move-room.ts +16 -16
- package/src/strategies/new-events-backfill.ts +98 -98
- package/src/strategies/off-route-points.ts +105 -105
- package/src/strategies/paradise-fish.knowledge.md +19 -19
- package/src/strategies/paradise-fish.ts +26 -26
- package/src/strategies/pathfind/distance-field.ts +150 -150
- package/src/strategies/pathfind/escape-planner.test.ts +197 -197
- package/src/strategies/pathfind/escape-planner.ts +355 -355
- package/src/strategies/pathfind/walkable-grid.ts +117 -117
- package/src/strategies/patrol.ts +12 -12
- package/src/strategies/player-targets.ts +13 -13
- package/src/strategies/report-patrol.ts +12 -12
- package/src/strategies/shrimp-memory.knowledge.md +19 -19
- package/src/strategies/shrimp-memory.ts +26 -26
- package/src/strategies/social-task.test.ts +28 -28
- package/src/strategies/social-task.ts +50 -50
- package/src/strategies/spawn.ts +82 -82
- package/src/strategies/speech-module.ts +123 -123
- package/src/strategies/strategy-loop.test.ts +15 -15
- package/src/strategies/strategy-loop.ts +776 -776
- package/src/strategies/task-kill-report.ts +18 -18
- package/src/strategies/task-only.ts +12 -12
- package/src/strategies/task-report.ts +23 -23
- package/src/strategies/types.ts +109 -109
- package/src/strategies/warrior-memory.knowledge.md +21 -21
- package/src/strategies/warrior-memory.ts +17 -17
|
@@ -1,369 +1,369 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 策略知识系统 — 当前对局的「推断快照」存储。
|
|
3
|
-
*
|
|
4
|
-
* 设计要点(见 docs/策略知识系统-by-claude.md):
|
|
5
|
-
* - 跨进程:Agent(短进程 CLI)写,strategy(长进程 loop)按 mtime 轮询读,免重启。
|
|
6
|
-
* - 快照而非日志:单个 JSON 对象,del/clear 直接重写;写入用临时文件 + rename 原子替换。
|
|
7
|
-
* - gameId 作用域:每份知识标记所属对局(= 最新 session 文件名),换局自动失效。
|
|
8
|
-
* - 价值中立:只存推断 + source,对 value 不做价值判断;
|
|
9
|
-
* 确定事实(role/队友/尸体…)走 state/ctx.teammates,不入库。
|
|
10
|
-
* - 玩家两档标记:只显式标 hostile(坏人)/ trusted(好人),未标记的一律默认「被怀疑」,不再有 suspect 标记,也不存置信度。
|
|
11
|
-
*/
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
13
|
-
import { basename, dirname, join } from 'path';
|
|
14
|
-
import { AuthStore } from './auth.js';
|
|
15
|
-
import { getProfileStateDir } from './init-command.js';
|
|
16
|
-
import { EventStore } from '../pipeline/event-store.js';
|
|
17
|
-
|
|
18
|
-
export type KnowledgeSource = 'agent' | 'strategy' | 'server';
|
|
19
|
-
/** v1:仅 'game' 生效(整库按 gameId 失效)。'session'/'persistent' 为预留,暂不跨局保留——跨局画像走 memory.md,CLI 也不暴露 --scope。 */
|
|
20
|
-
export type KnowledgeScope = 'game' | 'session' | 'persistent';
|
|
21
|
-
|
|
22
|
-
export interface FactMeta {
|
|
23
|
-
value: unknown;
|
|
24
|
-
note?: string;
|
|
25
|
-
source?: KnowledgeSource;
|
|
26
|
-
ts: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface TagMeta {
|
|
30
|
-
note?: string;
|
|
31
|
-
source?: KnowledgeSource;
|
|
32
|
-
ts: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 一个 subject(玩家/房间/任务/全局…)上聚合的事实 + 标签。 */
|
|
36
|
-
export interface SubjectEntry {
|
|
37
|
-
type: string; // 'player' | 'room' | 'task' | 'global' | <自定义>
|
|
38
|
-
selector: string; // player→座位号或名字;room→房名;global→''
|
|
39
|
-
facts?: Record<string, FactMeta>; // 键值事实,如 role: { value: 'impostor' }
|
|
40
|
-
tags?: Record<string, TagMeta>; // 玩家策略主标记:hostile / trusted(未标记默认被怀疑);也支持自定义标签
|
|
41
|
-
scope?: KnowledgeScope;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface KnowledgeFile {
|
|
45
|
-
version: 1;
|
|
46
|
-
gameId?: string;
|
|
47
|
-
subjects: Record<string, SubjectEntry>; // key = `${type}:${selector}`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type KnowledgeFileReadResult =
|
|
51
|
-
| { status: 'ok'; file: KnowledgeFile }
|
|
52
|
-
| { status: 'missing' }
|
|
53
|
-
| { status: 'invalid'; error: string };
|
|
54
|
-
|
|
55
|
-
export type PlayerRef = { name?: string; seat?: number };
|
|
56
|
-
export type SubjectRef = { type: string; selector: string };
|
|
57
|
-
export type KnowledgeRef = PlayerRef | SubjectRef;
|
|
58
|
-
/** 玩家显式标记只有两档:hostile(坏人)/ trusted(好人)。未标记 = 被怀疑(markOf 返回 undefined)。 */
|
|
59
|
-
export type PlayerMark = 'hostile' | 'trusted';
|
|
60
|
-
|
|
61
|
-
export interface KnowledgeView {
|
|
62
|
-
/** 当前没有任何知识时为 true。 */
|
|
63
|
-
readonly empty: boolean;
|
|
64
|
-
/** 取一个 subject(精确 type + selector)。 */
|
|
65
|
-
subject(type: string, selector: string): SubjectEntry | undefined;
|
|
66
|
-
/** 取某 subject 的键值事实。player ref 会按座位↔名字归一化解析。 */
|
|
67
|
-
getFact(ref: KnowledgeRef, key: string): FactMeta | undefined;
|
|
68
|
-
/** 该 subject 是否带某标签。玩家两档 mark 及旧别名按最新结论解析。 */
|
|
69
|
-
hasTag(ref: KnowledgeRef, tag: string): boolean;
|
|
70
|
-
/** 按 type / tag / factKey 筛选 subject。玩家 mark 标签同样遵守最新结论。 */
|
|
71
|
-
query(q: { type?: string; tag?: string; factKey?: string }): SubjectEntry[];
|
|
72
|
-
/** 某 type 下带某标签的全部 selector(喂给 matchesAnyTarget 用)。玩家 mark 标签同样过滤旧结论。 */
|
|
73
|
-
selectorsWithTag(type: string, tag: string): string[];
|
|
74
|
-
// —— player 便捷语法糖 ——
|
|
75
|
-
/** 最近一次 role 事实。身份事实与策略 mark 完全解耦。 */
|
|
76
|
-
roleOf(p: PlayerRef): string | undefined;
|
|
77
|
-
/** 最新显式标记;未标记返回 undefined(= 被怀疑)。 */
|
|
78
|
-
markOf(p: PlayerRef): PlayerMark | undefined;
|
|
79
|
-
/** 被怀疑 = 既非 hostile 也非 trusted(含完全未标记者)。 */
|
|
80
|
-
isSuspect(p: PlayerRef): boolean;
|
|
81
|
-
isHostile(p: PlayerRef): boolean;
|
|
82
|
-
isTrusted(p: PlayerRef): boolean;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const PLAYER_MARK_ALIASES: Record<string, PlayerMark> = {
|
|
86
|
-
hostile: 'hostile',
|
|
87
|
-
kill_if_armed: 'hostile',
|
|
88
|
-
trusted: 'trusted',
|
|
89
|
-
cleared: 'trusted',
|
|
90
|
-
protected: 'trusted',
|
|
91
|
-
do_not_kill: 'trusted',
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 两档化后被废弃的旧玩家标记:一律视同未标记(= 被怀疑),不映射到任何 canonical mark
|
|
96
|
-
*(「被怀疑」就是默认态,没有对应标签)。读取侧与写入侧共用这一份清单,避免漂移:
|
|
97
|
-
* - 读取侧(buildKnowledgeView.tagActive):旧数据里的 tags.suspect / tags.avoid 不再被 hasTag / query / selectorsWithTag 命中。
|
|
98
|
-
* - 写入侧(knowledge mark 的 removeTags):改判玩家时顺手删掉残留的 raw tag,否则 knowledge get / subject() 仍会暴露 tags.suspect/avoid 误导人。
|
|
99
|
-
*/
|
|
100
|
-
export const DROPPED_PLAYER_MARK_TAGS = new Set(['suspect', 'avoid']);
|
|
101
|
-
|
|
102
|
-
function nowTs(): number {
|
|
103
|
-
return Date.now();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function subjectKey(type: string, selector: string): string {
|
|
107
|
-
return `${type}:${selector}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 座位↔名字双向解析(比 game-utils.matchesTarget 多了「selector 是名字、ref 只有座位」一向),不依赖 StrategyContext(避免循环)。 */
|
|
111
|
-
function selectorMatchesPlayer(
|
|
112
|
-
selector: string,
|
|
113
|
-
player: PlayerRef,
|
|
114
|
-
playerNamesBySeat: Record<string, string>,
|
|
115
|
-
): boolean {
|
|
116
|
-
const seatStr = selector.trim();
|
|
117
|
-
const normalized = seatStr.toLowerCase();
|
|
118
|
-
if (!normalized) return false;
|
|
119
|
-
if (player.name && player.name.toLowerCase() === normalized) return true;
|
|
120
|
-
if (/^\d+$/.test(seatStr)) {
|
|
121
|
-
// 选择器是座位号
|
|
122
|
-
if (player.seat != null && String(player.seat) === seatStr) return true;
|
|
123
|
-
const nameFromSeat = playerNamesBySeat[seatStr];
|
|
124
|
-
if (nameFromSeat && player.name && player.name.toLowerCase() === nameFromSeat.toLowerCase()) return true;
|
|
125
|
-
} else if (player.seat != null) {
|
|
126
|
-
// 选择器是名字、引用只有座位 → 用座位映射出名字再比
|
|
127
|
-
const nameFromSeat = playerNamesBySeat[String(player.seat)];
|
|
128
|
-
if (nameFromSeat && nameFromSeat.toLowerCase() === normalized) return true;
|
|
129
|
-
}
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isSubjectRef(ref: KnowledgeRef): ref is SubjectRef {
|
|
134
|
-
return typeof (ref as SubjectRef).type === 'string' && typeof (ref as SubjectRef).selector === 'string';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** 当前对局 id = 最新 session 文件名(去扩展名);无对局时 undefined。 */
|
|
138
|
-
export function currentGameId(): string | undefined {
|
|
139
|
-
try {
|
|
140
|
-
const path = EventStore.latestSessionPath();
|
|
141
|
-
return path ? basename(path, '.jsonl') : undefined;
|
|
142
|
-
} catch {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function knowledgeFilePathForActiveAccount(): string {
|
|
148
|
-
const profile = new AuthStore().getActive();
|
|
149
|
-
if (!profile) throw new Error('Not logged in.');
|
|
150
|
-
return join(getProfileStateDir(profile), 'knowledge.json');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function readKnowledgeFileResult(path: string): KnowledgeFileReadResult {
|
|
154
|
-
if (!existsSync(path)) return { status: 'missing' };
|
|
155
|
-
try {
|
|
156
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
157
|
-
if (parsed && parsed.version === 1 && parsed.subjects && typeof parsed.subjects === 'object') {
|
|
158
|
-
return { status: 'ok', file: parsed as KnowledgeFile };
|
|
159
|
-
}
|
|
160
|
-
return { status: 'invalid', error: 'invalid knowledge file shape' };
|
|
161
|
-
} catch (err) {
|
|
162
|
-
return { status: 'invalid', error: err instanceof Error ? err.message : String(err) };
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** 读 knowledge.json;不存在 / 解析失败 / 结构不对都返回 null。需要区分错误原因时用 readKnowledgeFileResult。 */
|
|
167
|
-
export function readKnowledgeFile(path: string): KnowledgeFile | null {
|
|
168
|
-
const result = readKnowledgeFileResult(path);
|
|
169
|
-
return result.status === 'ok' ? result.file : null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** 临时文件 + rename 原子替换,避免读端读到写一半的 JSON。 */
|
|
173
|
-
export function writeKnowledgeFileAtomic(path: string, file: KnowledgeFile): void {
|
|
174
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
175
|
-
const tmp = `${path}.tmp.${process.pid}`;
|
|
176
|
-
writeFileSync(tmp, JSON.stringify(file, null, 2));
|
|
177
|
-
renameSync(tmp, path);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export interface KnowledgeSetInput {
|
|
181
|
-
type: string;
|
|
182
|
-
selector: string;
|
|
183
|
-
key?: string;
|
|
184
|
-
value?: unknown;
|
|
185
|
-
tags?: string[];
|
|
186
|
-
removeTags?: string[];
|
|
187
|
-
note?: string;
|
|
188
|
-
source?: KnowledgeSource;
|
|
189
|
-
scope?: KnowledgeScope;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Agent 侧读-改-写句柄;CLI 命令使用。strategy 侧只读,用 buildKnowledgeView。 */
|
|
193
|
-
export class KnowledgeStore {
|
|
194
|
-
constructor(
|
|
195
|
-
private readonly path: string,
|
|
196
|
-
readonly gameId?: string,
|
|
197
|
-
) {}
|
|
198
|
-
|
|
199
|
-
static forActiveAccount(): KnowledgeStore {
|
|
200
|
-
return new KnowledgeStore(knowledgeFilePathForActiveAccount(), currentGameId());
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** 读当前文件;若不存在或属于别的对局,返回该对局的空文件(旧局知识自动失效)。 */
|
|
204
|
-
load(): KnowledgeFile {
|
|
205
|
-
const file = readKnowledgeFile(this.path);
|
|
206
|
-
if (file && file.gameId === this.gameId) return file;
|
|
207
|
-
return { version: 1, gameId: this.gameId, subjects: {} };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private save(file: KnowledgeFile): void {
|
|
211
|
-
writeKnowledgeFileAtomic(this.path, file);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
set(input: KnowledgeSetInput): SubjectEntry {
|
|
215
|
-
if (!input.type || !input.selector) throw new Error('knowledge set requires type and selector');
|
|
216
|
-
const file = this.load();
|
|
217
|
-
const key = subjectKey(input.type, input.selector);
|
|
218
|
-
const entry: SubjectEntry = file.subjects[key] ?? { type: input.type, selector: input.selector };
|
|
219
|
-
const ts = nowTs();
|
|
220
|
-
const source = input.source ?? 'agent';
|
|
221
|
-
|
|
222
|
-
if (input.key != null) {
|
|
223
|
-
entry.facts = entry.facts ?? {};
|
|
224
|
-
entry.facts[input.key] = {
|
|
225
|
-
value: input.value === undefined ? true : input.value,
|
|
226
|
-
note: input.note,
|
|
227
|
-
source,
|
|
228
|
-
ts,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
if (input.tags && input.tags.length > 0) {
|
|
232
|
-
entry.tags = entry.tags ?? {};
|
|
233
|
-
for (const tag of input.tags) {
|
|
234
|
-
if (!tag) continue;
|
|
235
|
-
entry.tags[tag] = { note: input.note, source, ts };
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (input.removeTags && entry.tags) {
|
|
239
|
-
for (const tag of input.removeTags) delete entry.tags[tag];
|
|
240
|
-
}
|
|
241
|
-
if (input.scope) entry.scope = input.scope;
|
|
242
|
-
|
|
243
|
-
file.subjects[key] = entry;
|
|
244
|
-
this.save(file);
|
|
245
|
-
return entry;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** 删整个 subject,或仅删某个 fact / tag;删空后自动清除该 subject。 */
|
|
249
|
-
del(type: string, selector: string, opts?: { key?: string; tag?: string }): boolean {
|
|
250
|
-
const file = this.load();
|
|
251
|
-
const key = subjectKey(type, selector);
|
|
252
|
-
const entry = file.subjects[key];
|
|
253
|
-
if (!entry) return false;
|
|
254
|
-
|
|
255
|
-
if (!opts?.key && !opts?.tag) {
|
|
256
|
-
delete file.subjects[key];
|
|
257
|
-
this.save(file);
|
|
258
|
-
return true;
|
|
259
|
-
}
|
|
260
|
-
if (opts.key && entry.facts) delete entry.facts[opts.key];
|
|
261
|
-
if (opts.tag && entry.tags) delete entry.tags[opts.tag];
|
|
262
|
-
const noFacts = !entry.facts || Object.keys(entry.facts).length === 0;
|
|
263
|
-
const noTags = !entry.tags || Object.keys(entry.tags).length === 0;
|
|
264
|
-
if (noFacts && noTags) delete file.subjects[key];
|
|
265
|
-
this.save(file);
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
clear(): void {
|
|
270
|
-
this.save({ version: 1, gameId: this.gameId, subjects: {} });
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
list(): KnowledgeFile {
|
|
274
|
-
return this.load();
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** 由 strategy-loop 每次重载时构造。playerNamesBySeat 传入活引用即可,座位解析始终最新。 */
|
|
279
|
-
export function buildKnowledgeView(
|
|
280
|
-
file: KnowledgeFile | null,
|
|
281
|
-
opts: { playerNamesBySeat?: Record<string, string> } = {},
|
|
282
|
-
): KnowledgeView {
|
|
283
|
-
const subjects = file?.subjects ?? {};
|
|
284
|
-
const namesBySeat = opts.playerNamesBySeat ?? {};
|
|
285
|
-
|
|
286
|
-
const resolveAll = (ref: KnowledgeRef): SubjectEntry[] => {
|
|
287
|
-
if (isSubjectRef(ref)) {
|
|
288
|
-
const s = subjects[subjectKey(ref.type, ref.selector)];
|
|
289
|
-
return s ? [s] : [];
|
|
290
|
-
}
|
|
291
|
-
return Object.values(subjects).filter((s) => s.type === 'player' && selectorMatchesPlayer(s.selector, ref, namesBySeat));
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const latestFact = (ref: KnowledgeRef, key: string): FactMeta | undefined => {
|
|
295
|
-
let best: FactMeta | undefined;
|
|
296
|
-
for (const s of resolveAll(ref)) {
|
|
297
|
-
const f = s.facts?.[key];
|
|
298
|
-
if (f && (!best || f.ts > best.ts)) best = f;
|
|
299
|
-
}
|
|
300
|
-
return best;
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
type MarkVerdict = { kind: PlayerMark; ts: number };
|
|
304
|
-
// 同一时刻打了冲突标记时,trusted 优先级高于 hostile(好人判断更慎重,不轻易被覆盖)。
|
|
305
|
-
const markRank = (kind: PlayerMark): number => kind === 'trusted' ? 2 : 1;
|
|
306
|
-
const latestMark = (ref: KnowledgeRef): MarkVerdict | undefined => {
|
|
307
|
-
let best: MarkVerdict | undefined;
|
|
308
|
-
const take = (v: MarkVerdict) => {
|
|
309
|
-
if (!best
|
|
310
|
-
|| v.ts > best.ts
|
|
311
|
-
|| (v.ts === best.ts && markRank(v.kind) > markRank(best.kind))) {
|
|
312
|
-
best = v;
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
for (const s of resolveAll(ref)) {
|
|
316
|
-
for (const [tag, meta] of Object.entries(s.tags ?? {})) {
|
|
317
|
-
const kind = PLAYER_MARK_ALIASES[tag];
|
|
318
|
-
if (kind) take({ kind, ts: meta.ts });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return best;
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
const markKindForTag = (tag: string): PlayerMark | undefined => PLAYER_MARK_ALIASES[tag];
|
|
325
|
-
const tagActive = (s: SubjectEntry, tag: string): boolean => {
|
|
326
|
-
if (s.type === 'player') {
|
|
327
|
-
const requestedMark = markKindForTag(tag);
|
|
328
|
-
if (requestedMark) {
|
|
329
|
-
const carriesRequestedMark = Object.keys(s.tags ?? {}).some(candidate => markKindForTag(candidate) === requestedMark);
|
|
330
|
-
if (!carriesRequestedMark) return false;
|
|
331
|
-
const ref: PlayerRef = /^\d+$/.test(s.selector.trim()) ? { seat: Number(s.selector) } : { name: s.selector };
|
|
332
|
-
const mark = latestMark(ref);
|
|
333
|
-
return !!mark && mark.kind === requestedMark;
|
|
334
|
-
}
|
|
335
|
-
// 废弃的旧玩家标记(suspect/avoid)= 未标记 = 被怀疑,绝不命中;其余自定义标签(如 paradise_fish)照常按原值判定。
|
|
336
|
-
if (DROPPED_PLAYER_MARK_TAGS.has(tag)) return false;
|
|
337
|
-
}
|
|
338
|
-
return !!s.tags?.[tag];
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
empty: Object.keys(subjects).length === 0,
|
|
343
|
-
subject: (type, selector) => subjects[subjectKey(type, selector)],
|
|
344
|
-
getFact: (ref, key) => latestFact(ref, key),
|
|
345
|
-
hasTag: (ref, tag) => resolveAll(ref).some((s) => tagActive(s, tag)),
|
|
346
|
-
query: (q) => Object.values(subjects).filter((s) => {
|
|
347
|
-
if (q.type && s.type !== q.type) return false;
|
|
348
|
-
if (q.factKey && !s.facts?.[q.factKey]) return false;
|
|
349
|
-
if (q.tag && !tagActive(s, q.tag)) return false;
|
|
350
|
-
return true;
|
|
351
|
-
}),
|
|
352
|
-
selectorsWithTag: (type, tag) => Object.values(subjects)
|
|
353
|
-
.filter((s) => s.type === type && tagActive(s, tag))
|
|
354
|
-
.map((s) => s.selector),
|
|
355
|
-
roleOf: (p) => {
|
|
356
|
-
const fact = latestFact(p, 'role');
|
|
357
|
-
return typeof fact?.value === 'string' ? fact.value : undefined;
|
|
358
|
-
},
|
|
359
|
-
markOf: (p) => latestMark(p)?.kind,
|
|
360
|
-
// 未标记 hostile/trusted 的玩家一律「被怀疑」——这就是「除好人坏人外剩下全是被怀疑」的落点。
|
|
361
|
-
isSuspect: (p) => !latestMark(p),
|
|
362
|
-
isHostile: (p) => latestMark(p)?.kind === 'hostile',
|
|
363
|
-
isTrusted: (p) => latestMark(p)?.kind === 'trusted',
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export function emptyKnowledgeView(): KnowledgeView {
|
|
368
|
-
return buildKnowledgeView(null);
|
|
369
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 策略知识系统 — 当前对局的「推断快照」存储。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点(见 docs/策略知识系统-by-claude.md):
|
|
5
|
+
* - 跨进程:Agent(短进程 CLI)写,strategy(长进程 loop)按 mtime 轮询读,免重启。
|
|
6
|
+
* - 快照而非日志:单个 JSON 对象,del/clear 直接重写;写入用临时文件 + rename 原子替换。
|
|
7
|
+
* - gameId 作用域:每份知识标记所属对局(= 最新 session 文件名),换局自动失效。
|
|
8
|
+
* - 价值中立:只存推断 + source,对 value 不做价值判断;
|
|
9
|
+
* 确定事实(role/队友/尸体…)走 state/ctx.teammates,不入库。
|
|
10
|
+
* - 玩家两档标记:只显式标 hostile(坏人)/ trusted(好人),未标记的一律默认「被怀疑」,不再有 suspect 标记,也不存置信度。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
13
|
+
import { basename, dirname, join } from 'path';
|
|
14
|
+
import { AuthStore } from './auth.js';
|
|
15
|
+
import { getProfileStateDir } from './init-command.js';
|
|
16
|
+
import { EventStore } from '../pipeline/event-store.js';
|
|
17
|
+
|
|
18
|
+
export type KnowledgeSource = 'agent' | 'strategy' | 'server';
|
|
19
|
+
/** v1:仅 'game' 生效(整库按 gameId 失效)。'session'/'persistent' 为预留,暂不跨局保留——跨局画像走 memory.md,CLI 也不暴露 --scope。 */
|
|
20
|
+
export type KnowledgeScope = 'game' | 'session' | 'persistent';
|
|
21
|
+
|
|
22
|
+
export interface FactMeta {
|
|
23
|
+
value: unknown;
|
|
24
|
+
note?: string;
|
|
25
|
+
source?: KnowledgeSource;
|
|
26
|
+
ts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TagMeta {
|
|
30
|
+
note?: string;
|
|
31
|
+
source?: KnowledgeSource;
|
|
32
|
+
ts: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 一个 subject(玩家/房间/任务/全局…)上聚合的事实 + 标签。 */
|
|
36
|
+
export interface SubjectEntry {
|
|
37
|
+
type: string; // 'player' | 'room' | 'task' | 'global' | <自定义>
|
|
38
|
+
selector: string; // player→座位号或名字;room→房名;global→''
|
|
39
|
+
facts?: Record<string, FactMeta>; // 键值事实,如 role: { value: 'impostor' }
|
|
40
|
+
tags?: Record<string, TagMeta>; // 玩家策略主标记:hostile / trusted(未标记默认被怀疑);也支持自定义标签
|
|
41
|
+
scope?: KnowledgeScope;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface KnowledgeFile {
|
|
45
|
+
version: 1;
|
|
46
|
+
gameId?: string;
|
|
47
|
+
subjects: Record<string, SubjectEntry>; // key = `${type}:${selector}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type KnowledgeFileReadResult =
|
|
51
|
+
| { status: 'ok'; file: KnowledgeFile }
|
|
52
|
+
| { status: 'missing' }
|
|
53
|
+
| { status: 'invalid'; error: string };
|
|
54
|
+
|
|
55
|
+
export type PlayerRef = { name?: string; seat?: number };
|
|
56
|
+
export type SubjectRef = { type: string; selector: string };
|
|
57
|
+
export type KnowledgeRef = PlayerRef | SubjectRef;
|
|
58
|
+
/** 玩家显式标记只有两档:hostile(坏人)/ trusted(好人)。未标记 = 被怀疑(markOf 返回 undefined)。 */
|
|
59
|
+
export type PlayerMark = 'hostile' | 'trusted';
|
|
60
|
+
|
|
61
|
+
export interface KnowledgeView {
|
|
62
|
+
/** 当前没有任何知识时为 true。 */
|
|
63
|
+
readonly empty: boolean;
|
|
64
|
+
/** 取一个 subject(精确 type + selector)。 */
|
|
65
|
+
subject(type: string, selector: string): SubjectEntry | undefined;
|
|
66
|
+
/** 取某 subject 的键值事实。player ref 会按座位↔名字归一化解析。 */
|
|
67
|
+
getFact(ref: KnowledgeRef, key: string): FactMeta | undefined;
|
|
68
|
+
/** 该 subject 是否带某标签。玩家两档 mark 及旧别名按最新结论解析。 */
|
|
69
|
+
hasTag(ref: KnowledgeRef, tag: string): boolean;
|
|
70
|
+
/** 按 type / tag / factKey 筛选 subject。玩家 mark 标签同样遵守最新结论。 */
|
|
71
|
+
query(q: { type?: string; tag?: string; factKey?: string }): SubjectEntry[];
|
|
72
|
+
/** 某 type 下带某标签的全部 selector(喂给 matchesAnyTarget 用)。玩家 mark 标签同样过滤旧结论。 */
|
|
73
|
+
selectorsWithTag(type: string, tag: string): string[];
|
|
74
|
+
// —— player 便捷语法糖 ——
|
|
75
|
+
/** 最近一次 role 事实。身份事实与策略 mark 完全解耦。 */
|
|
76
|
+
roleOf(p: PlayerRef): string | undefined;
|
|
77
|
+
/** 最新显式标记;未标记返回 undefined(= 被怀疑)。 */
|
|
78
|
+
markOf(p: PlayerRef): PlayerMark | undefined;
|
|
79
|
+
/** 被怀疑 = 既非 hostile 也非 trusted(含完全未标记者)。 */
|
|
80
|
+
isSuspect(p: PlayerRef): boolean;
|
|
81
|
+
isHostile(p: PlayerRef): boolean;
|
|
82
|
+
isTrusted(p: PlayerRef): boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const PLAYER_MARK_ALIASES: Record<string, PlayerMark> = {
|
|
86
|
+
hostile: 'hostile',
|
|
87
|
+
kill_if_armed: 'hostile',
|
|
88
|
+
trusted: 'trusted',
|
|
89
|
+
cleared: 'trusted',
|
|
90
|
+
protected: 'trusted',
|
|
91
|
+
do_not_kill: 'trusted',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 两档化后被废弃的旧玩家标记:一律视同未标记(= 被怀疑),不映射到任何 canonical mark
|
|
96
|
+
*(「被怀疑」就是默认态,没有对应标签)。读取侧与写入侧共用这一份清单,避免漂移:
|
|
97
|
+
* - 读取侧(buildKnowledgeView.tagActive):旧数据里的 tags.suspect / tags.avoid 不再被 hasTag / query / selectorsWithTag 命中。
|
|
98
|
+
* - 写入侧(knowledge mark 的 removeTags):改判玩家时顺手删掉残留的 raw tag,否则 knowledge get / subject() 仍会暴露 tags.suspect/avoid 误导人。
|
|
99
|
+
*/
|
|
100
|
+
export const DROPPED_PLAYER_MARK_TAGS = new Set(['suspect', 'avoid']);
|
|
101
|
+
|
|
102
|
+
function nowTs(): number {
|
|
103
|
+
return Date.now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function subjectKey(type: string, selector: string): string {
|
|
107
|
+
return `${type}:${selector}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 座位↔名字双向解析(比 game-utils.matchesTarget 多了「selector 是名字、ref 只有座位」一向),不依赖 StrategyContext(避免循环)。 */
|
|
111
|
+
function selectorMatchesPlayer(
|
|
112
|
+
selector: string,
|
|
113
|
+
player: PlayerRef,
|
|
114
|
+
playerNamesBySeat: Record<string, string>,
|
|
115
|
+
): boolean {
|
|
116
|
+
const seatStr = selector.trim();
|
|
117
|
+
const normalized = seatStr.toLowerCase();
|
|
118
|
+
if (!normalized) return false;
|
|
119
|
+
if (player.name && player.name.toLowerCase() === normalized) return true;
|
|
120
|
+
if (/^\d+$/.test(seatStr)) {
|
|
121
|
+
// 选择器是座位号
|
|
122
|
+
if (player.seat != null && String(player.seat) === seatStr) return true;
|
|
123
|
+
const nameFromSeat = playerNamesBySeat[seatStr];
|
|
124
|
+
if (nameFromSeat && player.name && player.name.toLowerCase() === nameFromSeat.toLowerCase()) return true;
|
|
125
|
+
} else if (player.seat != null) {
|
|
126
|
+
// 选择器是名字、引用只有座位 → 用座位映射出名字再比
|
|
127
|
+
const nameFromSeat = playerNamesBySeat[String(player.seat)];
|
|
128
|
+
if (nameFromSeat && nameFromSeat.toLowerCase() === normalized) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isSubjectRef(ref: KnowledgeRef): ref is SubjectRef {
|
|
134
|
+
return typeof (ref as SubjectRef).type === 'string' && typeof (ref as SubjectRef).selector === 'string';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 当前对局 id = 最新 session 文件名(去扩展名);无对局时 undefined。 */
|
|
138
|
+
export function currentGameId(): string | undefined {
|
|
139
|
+
try {
|
|
140
|
+
const path = EventStore.latestSessionPath();
|
|
141
|
+
return path ? basename(path, '.jsonl') : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function knowledgeFilePathForActiveAccount(): string {
|
|
148
|
+
const profile = new AuthStore().getActive();
|
|
149
|
+
if (!profile) throw new Error('Not logged in.');
|
|
150
|
+
return join(getProfileStateDir(profile), 'knowledge.json');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function readKnowledgeFileResult(path: string): KnowledgeFileReadResult {
|
|
154
|
+
if (!existsSync(path)) return { status: 'missing' };
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
157
|
+
if (parsed && parsed.version === 1 && parsed.subjects && typeof parsed.subjects === 'object') {
|
|
158
|
+
return { status: 'ok', file: parsed as KnowledgeFile };
|
|
159
|
+
}
|
|
160
|
+
return { status: 'invalid', error: 'invalid knowledge file shape' };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { status: 'invalid', error: err instanceof Error ? err.message : String(err) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 读 knowledge.json;不存在 / 解析失败 / 结构不对都返回 null。需要区分错误原因时用 readKnowledgeFileResult。 */
|
|
167
|
+
export function readKnowledgeFile(path: string): KnowledgeFile | null {
|
|
168
|
+
const result = readKnowledgeFileResult(path);
|
|
169
|
+
return result.status === 'ok' ? result.file : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 临时文件 + rename 原子替换,避免读端读到写一半的 JSON。 */
|
|
173
|
+
export function writeKnowledgeFileAtomic(path: string, file: KnowledgeFile): void {
|
|
174
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
175
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
176
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2));
|
|
177
|
+
renameSync(tmp, path);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface KnowledgeSetInput {
|
|
181
|
+
type: string;
|
|
182
|
+
selector: string;
|
|
183
|
+
key?: string;
|
|
184
|
+
value?: unknown;
|
|
185
|
+
tags?: string[];
|
|
186
|
+
removeTags?: string[];
|
|
187
|
+
note?: string;
|
|
188
|
+
source?: KnowledgeSource;
|
|
189
|
+
scope?: KnowledgeScope;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Agent 侧读-改-写句柄;CLI 命令使用。strategy 侧只读,用 buildKnowledgeView。 */
|
|
193
|
+
export class KnowledgeStore {
|
|
194
|
+
constructor(
|
|
195
|
+
private readonly path: string,
|
|
196
|
+
readonly gameId?: string,
|
|
197
|
+
) {}
|
|
198
|
+
|
|
199
|
+
static forActiveAccount(): KnowledgeStore {
|
|
200
|
+
return new KnowledgeStore(knowledgeFilePathForActiveAccount(), currentGameId());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 读当前文件;若不存在或属于别的对局,返回该对局的空文件(旧局知识自动失效)。 */
|
|
204
|
+
load(): KnowledgeFile {
|
|
205
|
+
const file = readKnowledgeFile(this.path);
|
|
206
|
+
if (file && file.gameId === this.gameId) return file;
|
|
207
|
+
return { version: 1, gameId: this.gameId, subjects: {} };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private save(file: KnowledgeFile): void {
|
|
211
|
+
writeKnowledgeFileAtomic(this.path, file);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
set(input: KnowledgeSetInput): SubjectEntry {
|
|
215
|
+
if (!input.type || !input.selector) throw new Error('knowledge set requires type and selector');
|
|
216
|
+
const file = this.load();
|
|
217
|
+
const key = subjectKey(input.type, input.selector);
|
|
218
|
+
const entry: SubjectEntry = file.subjects[key] ?? { type: input.type, selector: input.selector };
|
|
219
|
+
const ts = nowTs();
|
|
220
|
+
const source = input.source ?? 'agent';
|
|
221
|
+
|
|
222
|
+
if (input.key != null) {
|
|
223
|
+
entry.facts = entry.facts ?? {};
|
|
224
|
+
entry.facts[input.key] = {
|
|
225
|
+
value: input.value === undefined ? true : input.value,
|
|
226
|
+
note: input.note,
|
|
227
|
+
source,
|
|
228
|
+
ts,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (input.tags && input.tags.length > 0) {
|
|
232
|
+
entry.tags = entry.tags ?? {};
|
|
233
|
+
for (const tag of input.tags) {
|
|
234
|
+
if (!tag) continue;
|
|
235
|
+
entry.tags[tag] = { note: input.note, source, ts };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (input.removeTags && entry.tags) {
|
|
239
|
+
for (const tag of input.removeTags) delete entry.tags[tag];
|
|
240
|
+
}
|
|
241
|
+
if (input.scope) entry.scope = input.scope;
|
|
242
|
+
|
|
243
|
+
file.subjects[key] = entry;
|
|
244
|
+
this.save(file);
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 删整个 subject,或仅删某个 fact / tag;删空后自动清除该 subject。 */
|
|
249
|
+
del(type: string, selector: string, opts?: { key?: string; tag?: string }): boolean {
|
|
250
|
+
const file = this.load();
|
|
251
|
+
const key = subjectKey(type, selector);
|
|
252
|
+
const entry = file.subjects[key];
|
|
253
|
+
if (!entry) return false;
|
|
254
|
+
|
|
255
|
+
if (!opts?.key && !opts?.tag) {
|
|
256
|
+
delete file.subjects[key];
|
|
257
|
+
this.save(file);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (opts.key && entry.facts) delete entry.facts[opts.key];
|
|
261
|
+
if (opts.tag && entry.tags) delete entry.tags[opts.tag];
|
|
262
|
+
const noFacts = !entry.facts || Object.keys(entry.facts).length === 0;
|
|
263
|
+
const noTags = !entry.tags || Object.keys(entry.tags).length === 0;
|
|
264
|
+
if (noFacts && noTags) delete file.subjects[key];
|
|
265
|
+
this.save(file);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
clear(): void {
|
|
270
|
+
this.save({ version: 1, gameId: this.gameId, subjects: {} });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
list(): KnowledgeFile {
|
|
274
|
+
return this.load();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 由 strategy-loop 每次重载时构造。playerNamesBySeat 传入活引用即可,座位解析始终最新。 */
|
|
279
|
+
export function buildKnowledgeView(
|
|
280
|
+
file: KnowledgeFile | null,
|
|
281
|
+
opts: { playerNamesBySeat?: Record<string, string> } = {},
|
|
282
|
+
): KnowledgeView {
|
|
283
|
+
const subjects = file?.subjects ?? {};
|
|
284
|
+
const namesBySeat = opts.playerNamesBySeat ?? {};
|
|
285
|
+
|
|
286
|
+
const resolveAll = (ref: KnowledgeRef): SubjectEntry[] => {
|
|
287
|
+
if (isSubjectRef(ref)) {
|
|
288
|
+
const s = subjects[subjectKey(ref.type, ref.selector)];
|
|
289
|
+
return s ? [s] : [];
|
|
290
|
+
}
|
|
291
|
+
return Object.values(subjects).filter((s) => s.type === 'player' && selectorMatchesPlayer(s.selector, ref, namesBySeat));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const latestFact = (ref: KnowledgeRef, key: string): FactMeta | undefined => {
|
|
295
|
+
let best: FactMeta | undefined;
|
|
296
|
+
for (const s of resolveAll(ref)) {
|
|
297
|
+
const f = s.facts?.[key];
|
|
298
|
+
if (f && (!best || f.ts > best.ts)) best = f;
|
|
299
|
+
}
|
|
300
|
+
return best;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
type MarkVerdict = { kind: PlayerMark; ts: number };
|
|
304
|
+
// 同一时刻打了冲突标记时,trusted 优先级高于 hostile(好人判断更慎重,不轻易被覆盖)。
|
|
305
|
+
const markRank = (kind: PlayerMark): number => kind === 'trusted' ? 2 : 1;
|
|
306
|
+
const latestMark = (ref: KnowledgeRef): MarkVerdict | undefined => {
|
|
307
|
+
let best: MarkVerdict | undefined;
|
|
308
|
+
const take = (v: MarkVerdict) => {
|
|
309
|
+
if (!best
|
|
310
|
+
|| v.ts > best.ts
|
|
311
|
+
|| (v.ts === best.ts && markRank(v.kind) > markRank(best.kind))) {
|
|
312
|
+
best = v;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
for (const s of resolveAll(ref)) {
|
|
316
|
+
for (const [tag, meta] of Object.entries(s.tags ?? {})) {
|
|
317
|
+
const kind = PLAYER_MARK_ALIASES[tag];
|
|
318
|
+
if (kind) take({ kind, ts: meta.ts });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return best;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const markKindForTag = (tag: string): PlayerMark | undefined => PLAYER_MARK_ALIASES[tag];
|
|
325
|
+
const tagActive = (s: SubjectEntry, tag: string): boolean => {
|
|
326
|
+
if (s.type === 'player') {
|
|
327
|
+
const requestedMark = markKindForTag(tag);
|
|
328
|
+
if (requestedMark) {
|
|
329
|
+
const carriesRequestedMark = Object.keys(s.tags ?? {}).some(candidate => markKindForTag(candidate) === requestedMark);
|
|
330
|
+
if (!carriesRequestedMark) return false;
|
|
331
|
+
const ref: PlayerRef = /^\d+$/.test(s.selector.trim()) ? { seat: Number(s.selector) } : { name: s.selector };
|
|
332
|
+
const mark = latestMark(ref);
|
|
333
|
+
return !!mark && mark.kind === requestedMark;
|
|
334
|
+
}
|
|
335
|
+
// 废弃的旧玩家标记(suspect/avoid)= 未标记 = 被怀疑,绝不命中;其余自定义标签(如 paradise_fish)照常按原值判定。
|
|
336
|
+
if (DROPPED_PLAYER_MARK_TAGS.has(tag)) return false;
|
|
337
|
+
}
|
|
338
|
+
return !!s.tags?.[tag];
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
empty: Object.keys(subjects).length === 0,
|
|
343
|
+
subject: (type, selector) => subjects[subjectKey(type, selector)],
|
|
344
|
+
getFact: (ref, key) => latestFact(ref, key),
|
|
345
|
+
hasTag: (ref, tag) => resolveAll(ref).some((s) => tagActive(s, tag)),
|
|
346
|
+
query: (q) => Object.values(subjects).filter((s) => {
|
|
347
|
+
if (q.type && s.type !== q.type) return false;
|
|
348
|
+
if (q.factKey && !s.facts?.[q.factKey]) return false;
|
|
349
|
+
if (q.tag && !tagActive(s, q.tag)) return false;
|
|
350
|
+
return true;
|
|
351
|
+
}),
|
|
352
|
+
selectorsWithTag: (type, tag) => Object.values(subjects)
|
|
353
|
+
.filter((s) => s.type === type && tagActive(s, tag))
|
|
354
|
+
.map((s) => s.selector),
|
|
355
|
+
roleOf: (p) => {
|
|
356
|
+
const fact = latestFact(p, 'role');
|
|
357
|
+
return typeof fact?.value === 'string' ? fact.value : undefined;
|
|
358
|
+
},
|
|
359
|
+
markOf: (p) => latestMark(p)?.kind,
|
|
360
|
+
// 未标记 hostile/trusted 的玩家一律「被怀疑」——这就是「除好人坏人外剩下全是被怀疑」的落点。
|
|
361
|
+
isSuspect: (p) => !latestMark(p),
|
|
362
|
+
isHostile: (p) => latestMark(p)?.kind === 'hostile',
|
|
363
|
+
isTrusted: (p) => latestMark(p)?.kind === 'trusted',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function emptyKnowledgeView(): KnowledgeView {
|
|
368
|
+
return buildKnowledgeView(null);
|
|
369
|
+
}
|