cortico 0.1.0

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.
Files changed (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 三份只读读数的渲染:背包、队列、上次没成的几条。
3
+ *
4
+ * 全是**现读现报**,一个字都不改世界、不动队列。放在这里而不是 world.ts 里,是因为
5
+ * 这三份的输入都是纯数据(快照 / 队列状态 / 受阻账),单测直接喂结构就能跑。
6
+ *
7
+ * 措辞与别处同一份来源:背包清单走 `narrateInventory`,队列走 `renderQueue` ——
8
+ * 同一件事两处措辞不同,读起来就像两件事(README「一件事只说一遍」)。
9
+ */
10
+ import type { QueueStatus, BlockedRecord } from './executor.ts';
11
+ import { renderQueue } from './executor.ts';
12
+ import type { GearPiece, GearSlot, WorldSnapshot } from './terrain.ts';
13
+ import { enchantSuffix, narrateInventory } from './terrain.ts';
14
+ import { zhName } from './names.ts';
15
+ import { PLAYER_SLOTS } from './precheck.ts';
16
+
17
+ const GEAR_SLOT_ZH: Record<GearSlot, string> = {
18
+ head: '头', chest: '胸', legs: '腿', feet: '脚', offhand: '副手',
19
+ };
20
+ const WORN_ORDER: readonly GearSlot[] = ['head', 'chest', 'legs', 'feet'];
21
+
22
+ function gearPhrase(p: GearPiece): string {
23
+ const dura = p.durability ? ` ${p.durability.left}/${p.durability.max}` : '';
24
+ return `${GEAR_SLOT_ZH[p.slot]}${zhName(p.name)}${enchantSuffix(p.enchantments)}${dura}`;
25
+ }
26
+
27
+ /**
28
+ * 背包快照包括槽位占用、物品总量、手持和装备。
29
+ * 首次服务端同步前报告未同步,避免将初始空视图当成空背包。
30
+ */
31
+ export function renderBagReadout(s: WorldSnapshot): string {
32
+ if (!s.invSynced) return '[背包] 物品栏还在从服务器同步,这份清单还没到。';
33
+ const used = s.inventory.length;
34
+ const held = s.heldItem ? `手里拿着${zhName(s.heldItem)}` : '手里空着';
35
+ const worn = WORN_ORDER
36
+ .map((slot) => s.equipment.find((p) => p.slot === slot))
37
+ .filter((p): p is GearPiece => p !== undefined);
38
+ const off = s.equipment.find((p) => p.slot === 'offhand');
39
+ const lines = [
40
+ `[背包] ${used}/${PLAYER_SLOTS} 格占着,空 ${PLAYER_SLOTS - used} 格。`,
41
+ used > 0 ? `包里:${narrateInventory(s.inventory)}。` : '包里什么都没有。',
42
+ `${held}。`,
43
+ worn.length > 0 ? `穿着:${worn.map(gearPhrase).join('、')}。` : '身上没穿护甲。',
44
+ ];
45
+ if (off) lines.push(`${gearPhrase(off)}。`);
46
+ return lines.join('\n');
47
+ }
48
+
49
+ /** 背包读数的指纹:一轮一答闸按它判「这一份读数变没变」(见 round.ts) */
50
+ export function bagStamp(s: WorldSnapshot | null): string {
51
+ if (!s) return 'nobot';
52
+ if (!s.invSynced) return 'nosync';
53
+ return [
54
+ s.inventory.length,
55
+ [...s.inventory].map((i) => `${i.name}${enchantSuffix(i.enchantments)}×${i.count}`).sort().join(','),
56
+ s.heldItem ?? 'bare',
57
+ [...s.equipment].map((p) => `${p.slot}:${p.name}`).sort().join(','),
58
+ ].join('|');
59
+ }
60
+
61
+ /**
62
+ * 队列现状:在做的那件与它跑到第几步、排队的、最近一单的终态。
63
+ *
64
+ * 最近一单的终态是这份读数里唯一"过去时"的东西 —— 它正是「我刚才那一单到底怎么了」
65
+ * 这个问题的答案,而那条终态回执早被交接或后续事件挤出上下文了。
66
+ */
67
+ export function renderQueueReadout(
68
+ q: QueueStatus,
69
+ last: { at: string; kind: string; text: string } | null,
70
+ ): string {
71
+ const lines = [`[队列] ${renderQueue(q)}`];
72
+ lines.push(last ? `[最近一单] ${last.at} ${last.text}` : '[最近一单] 这一场还没有跑完过任何一单。');
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /** 队列读数的指纹。**不含时钟** —— 已跑多少秒每次都在变,进了指纹这道闸等于不存在 */
77
+ export function queueStamp(q: QueueStatus, lastAt: number | null): string {
78
+ const r = q.running;
79
+ return [
80
+ r ? `${r.id}:${r.stepIndex}/${r.stepCount}:${r.count ? `${r.count.done}/${r.count.total}` : '-'}` : 'idle',
81
+ q.waiting.map((w) => w.id).join(','),
82
+ q.hold ?? '-',
83
+ lastAt ?? '-',
84
+ ].join('|');
85
+ }
86
+
87
+ /**
88
+ * 最近几条没做成的记录:时刻、任务、步、原文原因。
89
+ *
90
+ * 只搬账本上已有的字,一个字不改写、不归因、不给建议(worlds-report-facts)。
91
+ * 「同一类撞了几次」那件事另有头条在终态回执里报,这里不重复。
92
+ */
93
+ export function renderBlockedReadout(records: readonly BlockedRecord[], clock: (ms: number) => string): string {
94
+ if (records.length === 0) return '[上次没成] 这一场还没有记到受阻的步。';
95
+ const lines = records.map((r) => `${clock(r.at)} ${r.task}${r.step ? ` ${r.step}` : ''}:${r.why}`);
96
+ return `[上次没成] 最近 ${records.length} 条(新的在前):\n${lines.join('\n')}`;
97
+ }
98
+
99
+ /** 受阻读数的指纹:最新那一条的时刻 + 条数,两者都没动就是同一份读数 */
100
+ export function blockedStamp(records: readonly BlockedRecord[]): string {
101
+ return `${records.length}|${records[0]?.at ?? '-'}`;
102
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 同一轮、同一工具且读数指纹相同的重复查询返回简短回执;状态变化时重新完整回答。
3
+ * 指纹排除时钟字段,避免经过时间造成虚假的状态变化。
4
+ * 主循环的 round 经代理 IPC 原样传入子进程的 ctx.round;缺少轮号时每次正常回答。
5
+ */
6
+ import type { ToolCallContext } from '../../core/types.ts';
7
+
8
+ export function roundTokenOf(ctx: ToolCallContext | undefined): number | null {
9
+ if (!ctx) return null;
10
+ if (typeof ctx.round === 'number') return ctx.round;
11
+ return null;
12
+ }
13
+
14
+ export const REPEATED_QUERY_RECEIPT = '这轮已经答过了,答案不会变,先看上一条。';
15
+
16
+ export class RoundOnceGate {
17
+ private readonly last = new Map<string, { round: number; stamp: string }>();
18
+
19
+ answered(tool: string, round: number | null, stamp: string): boolean {
20
+ if (round === null) return true;
21
+ const prev = this.last.get(tool);
22
+ if (prev && prev.round === round && prev.stamp === stamp) return false;
23
+ this.last.set(tool, { round, stamp });
24
+ return true;
25
+ }
26
+ }
@@ -0,0 +1,92 @@
1
+ export type FindKind = 'block' | 'entity';
2
+
3
+ export interface SearchScope {
4
+ connectionGeneration: number;
5
+ realm: string;
6
+ dimension: string;
7
+ }
8
+
9
+ interface ObservedFindHit {
10
+ kind: FindKind;
11
+ target: string;
12
+ what: string;
13
+ at: [number, number, number];
14
+ observedAt: number;
15
+ }
16
+
17
+ interface AgedFindHit extends ObservedFindHit {
18
+ ageMs: number;
19
+ }
20
+
21
+ const HISTORY_TTL_MS = 15 * 60_000;
22
+ const HISTORY_LIMIT = 128;
23
+
24
+ function canonicalFindTarget(target: string): string {
25
+ return target.trim().toLowerCase().replace(/^minecraft:/, '');
26
+ }
27
+
28
+ function scopeKey(scope: SearchScope): string {
29
+ return `${scope.connectionGeneration}\0${scope.realm}\0${scope.dimension}`;
30
+ }
31
+
32
+ function observationKey(scope: SearchScope, target: string, kind: FindKind): string {
33
+ return `${scopeKey(scope)}\0${kind}\0${canonicalFindTarget(target)}`;
34
+ }
35
+
36
+ /** Recent sightings are process-local evidence and never survive a connection or dimension boundary. */
37
+ export class FindObservationCache {
38
+ private entries = new Map<string, ObservedFindHit>();
39
+ private activeScope: string | null = null;
40
+
41
+ constructor(
42
+ private readonly ttlMs = HISTORY_TTL_MS,
43
+ private readonly limit = HISTORY_LIMIT,
44
+ ) {}
45
+
46
+ sync(scope: SearchScope): void {
47
+ const next = scopeKey(scope);
48
+ if (this.activeScope !== null && this.activeScope !== next) this.entries.clear();
49
+ this.activeScope = next;
50
+ }
51
+
52
+ remember(
53
+ scope: SearchScope,
54
+ hit: Omit<ObservedFindHit, 'observedAt' | 'target'> & { target: string; observedAt?: number },
55
+ ): void {
56
+ this.sync(scope);
57
+ const observedAt = hit.observedAt ?? Date.now();
58
+ this.purge(observedAt);
59
+ const key = observationKey(scope, hit.target, hit.kind);
60
+ this.entries.delete(key);
61
+ this.entries.set(key, { ...hit, target: canonicalFindTarget(hit.target), observedAt });
62
+ while (this.entries.size > this.limit) this.entries.delete(this.entries.keys().next().value!);
63
+ }
64
+
65
+ recall(
66
+ scope: SearchScope,
67
+ target: string,
68
+ kind: FindKind,
69
+ now = Date.now(),
70
+ ): AgedFindHit | null {
71
+ this.sync(scope);
72
+ this.purge(now);
73
+ const key = observationKey(scope, target, kind);
74
+ const hit = this.entries.get(key);
75
+ if (!hit) return null;
76
+ this.entries.delete(key);
77
+ this.entries.set(key, hit);
78
+ return { ...hit, ageMs: Math.max(0, now - hit.observedAt) };
79
+ }
80
+
81
+ clear(): void {
82
+ this.entries.clear();
83
+ this.activeScope = null;
84
+ }
85
+
86
+ private purge(now: number): void {
87
+ for (const [key, hit] of this.entries) {
88
+ if (now - hit.observedAt > this.ttlMs) this.entries.delete(key);
89
+ }
90
+ }
91
+ }
92
+
@@ -0,0 +1,467 @@
1
+ /** 读取和写入 server.properties,列出存档及其 level.dat 元数据。生成参数仅用于生成区块,不重新生成已有区块。 */
2
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, type Dirent } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { readLevelDat, type LevelDatInfo } from './level-dat.ts';
5
+ import { offlineUuid } from './client-launch.ts';
6
+
7
+ export const GAMEMODES = ['survival', 'creative', 'adventure', 'spectator'] as const;
8
+ export const DIFFICULTIES = ['peaceful', 'easy', 'normal', 'hard'] as const;
9
+ const LEVEL_TYPES = [
10
+ 'minecraft:normal',
11
+ 'minecraft:flat',
12
+ 'minecraft:large_biomes',
13
+ 'minecraft:amplified',
14
+ 'minecraft:single_biome_surface',
15
+ ] as const;
16
+
17
+ type Gamemode = (typeof GAMEMODES)[number];
18
+ export type Difficulty = (typeof DIFFICULTIES)[number];
19
+ export type LevelType = (typeof LEVEL_TYPES)[number];
20
+
21
+ export const LEVEL_TYPE_LABELS: ReadonlyArray<{ value: LevelType; label: string; note: string }> = [
22
+ { value: 'minecraft:normal', label: '默认', note: '原版地形' },
23
+ { value: 'minecraft:flat', label: '超平坦', note: '按生成器细则逐层铺;不填就是原版那三层' },
24
+ { value: 'minecraft:large_biomes', label: '大型生物群系', note: '同样的地形,群系尺度放大' },
25
+ { value: 'minecraft:amplified', label: '放大化', note: '极端高山地形,吃性能' },
26
+ {
27
+ value: 'minecraft:single_biome_surface',
28
+ label: '单一生物群系',
29
+ note: '整个世界一个群系,群系名写在生成器细则里({"biome":"minecraft:desert"})',
30
+ },
31
+ ];
32
+
33
+ export function levelTypeLabel(type: LevelType): string {
34
+ return LEVEL_TYPE_LABELS.find((t) => t.value === type)?.label ?? type;
35
+ }
36
+
37
+ /** 超平坦预设的方块层和结构参数。 */
38
+ export const FLAT_PRESETS: ReadonlyArray<{ id: string; label: string; note: string; json: string }> = [
39
+ {
40
+ id: 'classic',
41
+ label: '经典平坦',
42
+ note: '基岩 + 2 层泥土 + 草方块,平原群系',
43
+ json: '{"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},'
44
+ + '{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}',
45
+ },
46
+ {
47
+ id: 'void',
48
+ label: '虚空',
49
+ note: '只有一层空气,出生点一小块平台',
50
+ json: '{"layers":[{"block":"minecraft:air","height":1}],"biome":"minecraft:the_void"}',
51
+ },
52
+ {
53
+ id: 'tunnelers',
54
+ label: '隧道世界',
55
+ note: '基岩 + 230 层石头,整个世界都是可挖的岩层',
56
+ json: '{"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:stone","height":230}],'
57
+ + '"biome":"minecraft:windswept_hills"}',
58
+ },
59
+ {
60
+ id: 'minable',
61
+ label: '可挖平坦',
62
+ note: '基岩 + 59 层石头 + 泥土草皮:地表是平的,底下有矿脉',
63
+ json: '{"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:stone","height":59},'
64
+ + '{"block":"minecraft:dirt","height":3},{"block":"minecraft:grass_block","height":1}],'
65
+ + '"biome":"minecraft:plains"}',
66
+ },
67
+ ];
68
+
69
+ export interface GameSettings {
70
+ gamemode: Gamemode;
71
+ difficulty: Difficulty;
72
+ hardcore: boolean;
73
+ pvp: boolean;
74
+ spawnMonsters: boolean;
75
+ levelSeed: string;
76
+ /** 当前存档目录名 */
77
+ levelName: string;
78
+ levelType: LevelType;
79
+ /** 生成器细则(超平坦层配方 / 单群系名);空 = 该类型的默认 */
80
+ generatorSettings: string;
81
+ /** 村庄、神殿、要塞这些结构 */
82
+ generateStructures: boolean;
83
+ allowNether: boolean;
84
+ /** 出生点保护半径(格);0 = 不保护 */
85
+ spawnProtection: number;
86
+ viewDistance: number;
87
+ simulationDistance: number;
88
+ /** 世界边界半径(格) */
89
+ maxWorldSize: number;
90
+ }
91
+
92
+ export interface WorldInfo {
93
+ name: string;
94
+ generated: boolean;
95
+ modified: string | null;
96
+ sizeBytes: number;
97
+ /** 已生成的附属维度:nether / the_end */
98
+ dimensions: string[];
99
+ info: LevelDatInfo | null;
100
+ }
101
+
102
+ /** 一行 properties:注释与空行原样留着 */
103
+ type Line = { kind: 'raw'; text: string } | { kind: 'pair'; key: string; value: string };
104
+
105
+ export function parseProperties(text: string): Line[] {
106
+ return text.split(/\r?\n/).map((line) => {
107
+ const trimmed = line.trim();
108
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) return { kind: 'raw', text: line };
109
+ const eq = line.indexOf('=');
110
+ if (eq < 0) return { kind: 'raw', text: line };
111
+ return { kind: 'pair', key: line.slice(0, eq).trim(), value: line.slice(eq + 1) };
112
+ });
113
+ }
114
+
115
+ export function stringifyProperties(lines: Line[]): string {
116
+ return lines.map((l) => (l.kind === 'raw' ? l.text : `${l.key}=${l.value}`)).join('\n');
117
+ }
118
+
119
+ export function readProperty(lines: Line[], key: string): string | null {
120
+ for (const l of lines) if (l.kind === 'pair' && l.key === key) return l.value;
121
+ return null;
122
+ }
123
+
124
+ /** 就地改;没有这个键就追加在末尾 */
125
+ export function writeProperty(lines: Line[], key: string, value: string): Line[] {
126
+ const escaped = value.replace(/\\/g, '\\\\').replace(/[\r\n]/g, ' ');
127
+ let hit = false;
128
+ const out = lines.map((l) => {
129
+ if (l.kind === 'pair' && l.key === key) { hit = true; return { kind: 'pair' as const, key, value: escaped }; }
130
+ return l;
131
+ });
132
+ if (!hit) out.push({ kind: 'pair', key, value: escaped });
133
+ return out;
134
+ }
135
+
136
+ function pickEnum<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
137
+ const v = (raw ?? '').trim().toLowerCase();
138
+ return (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
139
+ }
140
+
141
+ function pickBool(raw: string | null, fallback: boolean): boolean {
142
+ const v = (raw ?? '').trim().toLowerCase();
143
+ if (v === 'true') return true;
144
+ if (v === 'false') return false;
145
+ return fallback;
146
+ }
147
+
148
+ function pickInt(raw: string | null, fallback: number, lo: number, hi: number): number {
149
+ const n = Number.parseInt((raw ?? '').trim(), 10);
150
+ if (!Number.isFinite(n)) return fallback;
151
+ return Math.min(hi, Math.max(lo, n));
152
+ }
153
+
154
+ /** 1.19 之前 level-type 是不带命名空间的短名(flat / largeBiomes);两种写法都收 */
155
+ function pickLevelType(raw: string | null): LevelType {
156
+ const v = (raw ?? '').trim();
157
+ const short = v.includes(':') ? v.slice(v.indexOf(':') + 1) : v;
158
+ const id = `minecraft:${short.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}`;
159
+ const legacy: Record<string, LevelType> = { 'minecraft:default': 'minecraft:normal' };
160
+ const resolved = legacy[id] ?? id;
161
+ return (LEVEL_TYPES as readonly string[]).includes(resolved) ? (resolved as LevelType) : 'minecraft:normal';
162
+ }
163
+
164
+ export function settingsFrom(lines: Line[]): GameSettings {
165
+ return {
166
+ gamemode: pickEnum(readProperty(lines, 'gamemode'), GAMEMODES, 'survival'),
167
+ difficulty: pickEnum(readProperty(lines, 'difficulty'), DIFFICULTIES, 'easy'),
168
+ hardcore: pickBool(readProperty(lines, 'hardcore'), false),
169
+ pvp: pickBool(readProperty(lines, 'pvp'), true),
170
+ spawnMonsters: pickBool(readProperty(lines, 'spawn-monsters'), true),
171
+ levelSeed: (readProperty(lines, 'level-seed') ?? '').trim(),
172
+ levelName: (readProperty(lines, 'level-name') ?? 'world').trim() || 'world',
173
+ levelType: pickLevelType(readProperty(lines, 'level-type')),
174
+ generatorSettings: (readProperty(lines, 'generator-settings') ?? '').trim(),
175
+ generateStructures: pickBool(readProperty(lines, 'generate-structures'), true),
176
+ allowNether: pickBool(readProperty(lines, 'allow-nether'), true),
177
+ spawnProtection: pickInt(readProperty(lines, 'spawn-protection'), 16, 0, 512),
178
+ viewDistance: pickInt(readProperty(lines, 'view-distance'), 10, 2, 32),
179
+ simulationDistance: pickInt(readProperty(lines, 'simulation-distance'), 10, 2, 32),
180
+ maxWorldSize: pickInt(readProperty(lines, 'max-world-size'), 29_999_984, 1, 29_999_984),
181
+ };
182
+ }
183
+
184
+ export function applySettings(lines: Line[], patch: Partial<GameSettings>): Line[] {
185
+ let out = lines;
186
+ const set = (key: string, value: string | number | boolean | undefined): void => {
187
+ if (value !== undefined) out = writeProperty(out, key, String(value));
188
+ };
189
+ set('gamemode', patch.gamemode);
190
+ set('difficulty', patch.difficulty);
191
+ set('hardcore', patch.hardcore);
192
+ set('pvp', patch.pvp);
193
+ set('spawn-monsters', patch.spawnMonsters);
194
+ set('level-seed', patch.levelSeed);
195
+ set('level-name', patch.levelName);
196
+ set('level-type', patch.levelType);
197
+ set('generator-settings', patch.generatorSettings);
198
+ set('generate-structures', patch.generateStructures);
199
+ set('allow-nether', patch.allowNether);
200
+ set('spawn-protection', patch.spawnProtection);
201
+ set('view-distance', patch.viewDistance);
202
+ set('simulation-distance', patch.simulationDistance);
203
+ set('max-world-size', patch.maxWorldSize);
204
+ return out;
205
+ }
206
+
207
+ /** 存档名用作目录名;拒绝路径分隔符、相对路径和 Windows 保留名称。 */
208
+ export function validWorldName(name: string): string | null {
209
+ const n = name.trim();
210
+ if (!n) return '存档名不能为空';
211
+ if (n.length > 64) return '存档名太长(最多 64 字)';
212
+ if (/[\\/:*?"<>|]/.test(n)) return '存档名里不能有 \\ / : * ? " < > |';
213
+ if (n === '.' || n === '..') return '这个名字不能用';
214
+ return null;
215
+ }
216
+
217
+ export function validGeneratorSettings(text: string): string | null {
218
+ const t = text.trim();
219
+ if (!t) return null;
220
+ let parsed: unknown;
221
+ try {
222
+ parsed = JSON.parse(t);
223
+ } catch (err) {
224
+ return `生成器细则不是合法 JSON: ${(err as Error).message}`;
225
+ }
226
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
227
+ return '生成器细则要是一个 JSON 对象(如 {"layers":[…],"biome":"…"})';
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /** Paper/Bukkit 把下界与末地拆成独立目录,它们不是可选的存档 */
233
+ const DIMENSION_SUFFIX = /_(nether|the_end)$/;
234
+
235
+ const sizeCache = new Map<string, { stamp: number; size: number }>();
236
+
237
+ function dirSize(dir: string, budget: { left: number }): number {
238
+ let total = 0;
239
+ let entries: Dirent[];
240
+ try {
241
+ entries = readdirSync(dir, { withFileTypes: true });
242
+ } catch {
243
+ return 0;
244
+ }
245
+ for (const entry of entries) {
246
+ if (budget.left-- <= 0) return total;
247
+ const abs = join(dir, entry.name);
248
+ if (entry.isDirectory()) total += dirSize(abs, budget);
249
+ else {
250
+ try { total += statSync(abs).size; } catch { }
251
+ }
252
+ }
253
+ return total;
254
+ }
255
+
256
+ function worldSize(serverDir: string, name: string, stamp: number): number {
257
+ const key = join(serverDir, name);
258
+ const hit = sizeCache.get(key);
259
+ if (hit && hit.stamp === stamp) return hit.size;
260
+ const budget = { left: 200_000 };
261
+ let size = dirSize(key, budget);
262
+ for (const suffix of ['_nether', '_the_end']) {
263
+ const dim = join(serverDir, `${name}${suffix}`);
264
+ if (existsSync(dim)) size += dirSize(dim, budget);
265
+ }
266
+ sizeCache.set(key, { stamp, size });
267
+ return size;
268
+ }
269
+
270
+ /** serverDir 下的存档目录:有 level.dat 的算已生成,最近活跃的排在前面 */
271
+ export function listWorlds(serverDir: string, current: string): WorldInfo[] {
272
+ const found = new Map<string, WorldInfo>();
273
+ if (serverDir && existsSync(serverDir)) {
274
+ const names = new Set<string>();
275
+ for (const entry of readdirSync(serverDir, { withFileTypes: true })) {
276
+ if (entry.isDirectory()) names.add(entry.name);
277
+ }
278
+ for (const name of names) {
279
+ if (DIMENSION_SUFFIX.test(name)) continue;
280
+ const dat = join(serverDir, name, 'level.dat');
281
+ if (!existsSync(dat)) continue;
282
+ const mtime = statSync(dat).mtimeMs;
283
+ const info = readLevelDat(dat);
284
+ found.set(name, {
285
+ name,
286
+ generated: true,
287
+ /** 优先使用 LastPlayed;缺失时使用 level.dat 的修改时间。 */
288
+ modified: new Date(info?.lastPlayed || mtime).toISOString(),
289
+ sizeBytes: worldSize(serverDir, name, mtime),
290
+ dimensions: ['nether', 'the_end'].filter((d) => names.has(`${name}_${d}`)),
291
+ info,
292
+ });
293
+ }
294
+ }
295
+ if (current && !found.has(current)) {
296
+ found.set(current, {
297
+ name: current, generated: false, modified: null, sizeBytes: 0, dimensions: [], info: null,
298
+ });
299
+ }
300
+ return [...found.values()].sort((a, b) => {
301
+ const at = a.modified ? Date.parse(a.modified) : -1;
302
+ const bt = b.modified ? Date.parse(b.modified) : -1;
303
+ return bt - at || a.name.localeCompare(b.name);
304
+ });
305
+ }
306
+
307
+ function propertiesPath(serverDir: string): string {
308
+ return join(serverDir, 'server.properties');
309
+ }
310
+
311
+ export function loadProperties(serverDir: string): Line[] {
312
+ const file = propertiesPath(serverDir);
313
+ return existsSync(file) ? parseProperties(readFileSync(file, 'utf8')) : [];
314
+ }
315
+
316
+ export function saveProperties(serverDir: string, lines: Line[]): void {
317
+ writeFileSync(propertiesPath(serverDir), stringifyProperties(lines), 'utf8');
318
+ }
319
+
320
+ /** 存档身份决定目标、路标和施工绑定所属的 realm。 */
321
+ export interface WorldIdentity {
322
+ key: string;
323
+ local: boolean;
324
+ }
325
+
326
+ export function worldIdentityOf(serverDir: string, address: string): WorldIdentity {
327
+ if (serverDir && existsSync(serverDir)) {
328
+ try {
329
+ const name = settingsFrom(loadProperties(serverDir)).levelName;
330
+ if (name) return { key: name, local: true };
331
+ } catch { /* 配置缺损:退回地址 */ }
332
+ }
333
+ return { key: address, local: false };
334
+ }
335
+
336
+ /** 环境提示词中的存档或服务器身份。 */
337
+ export function worldEnvLine(world: WorldIdentity): string {
338
+ return world.local ? `当前存档:「${world.key}」` : `当前服务器:${world.key}`;
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // 权限与作弊:ops.json 与那几项决定"谁能下命令"的 properties
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /** 写入 ops.json 中的操作员权限;运行中另发送 op/deop 指令。 */
346
+ export interface AccessSettings {
347
+ opPermissionLevel: number;
348
+ /** 命令方块能不能用 */
349
+ enableCommandBlock: boolean;
350
+ /** 创造/旁观之外的飞行不被踢 */
351
+ allowFlight: boolean;
352
+ /** online-mode=true 时服务器会验证账户,离线账户无法通过验证。 */
353
+ onlineMode: boolean;
354
+ /** 开启白名单时,账户还需列入白名单。 */
355
+ whiteList: boolean;
356
+ }
357
+
358
+ export function accessFrom(lines: Line[]): AccessSettings {
359
+ return {
360
+ opPermissionLevel: pickInt(readProperty(lines, 'op-permission-level'), 4, 1, 4),
361
+ enableCommandBlock: pickBool(readProperty(lines, 'enable-command-block'), false),
362
+ allowFlight: pickBool(readProperty(lines, 'allow-flight'), false),
363
+ onlineMode: pickBool(readProperty(lines, 'online-mode'), true),
364
+ whiteList: pickBool(readProperty(lines, 'white-list'), false),
365
+ };
366
+ }
367
+
368
+ export function applyAccess(lines: Line[], patch: Partial<AccessSettings>): Line[] {
369
+ let out = lines;
370
+ const set = (key: string, value: string | number | boolean | undefined): void => {
371
+ if (value !== undefined) out = writeProperty(out, key, String(value));
372
+ };
373
+ set('op-permission-level', patch.opPermissionLevel);
374
+ set('enable-command-block', patch.enableCommandBlock);
375
+ set('allow-flight', patch.allowFlight);
376
+ set('online-mode', patch.onlineMode);
377
+ set('white-list', patch.whiteList);
378
+ return out;
379
+ }
380
+
381
+ interface OpEntry {
382
+ uuid: string;
383
+ name: string;
384
+ level: number;
385
+ bypassesPlayerLimit: boolean;
386
+ }
387
+
388
+ function opsPath(serverDir: string): string {
389
+ return join(serverDir, 'ops.json');
390
+ }
391
+
392
+ export function readOps(serverDir: string): OpEntry[] {
393
+ const file = opsPath(serverDir);
394
+ if (!serverDir || !existsSync(file)) return [];
395
+ try {
396
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
397
+ if (!Array.isArray(parsed)) return [];
398
+ return parsed.filter((e): e is OpEntry => typeof e === 'object' && e !== null && 'name' in e);
399
+ } catch {
400
+ return [];
401
+ }
402
+ }
403
+
404
+ export function writeOps(serverDir: string, ops: OpEntry[]): void {
405
+ writeFileSync(opsPath(serverDir), `${JSON.stringify(ops, null, 2)}\n`, 'utf8');
406
+ }
407
+
408
+ /** 优先采用 usercache.json 中的 UUID;缺失时按区分大小写的账户名计算离线 UUID。 */
409
+ export function resolveUuid(serverDir: string, name: string): string {
410
+ const file = join(serverDir, 'usercache.json');
411
+ if (serverDir && existsSync(file)) {
412
+ try {
413
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
414
+ if (Array.isArray(parsed)) {
415
+ const hit = parsed.find(
416
+ (e) => typeof e === 'object' && e !== null && (e as { name?: unknown }).name === name,
417
+ ) as { uuid?: unknown } | undefined;
418
+ if (typeof hit?.uuid === 'string') return hit.uuid;
419
+ }
420
+ } catch {
421
+ /* 缓存读坏了就算离线 UUID */
422
+ }
423
+ }
424
+ return offlineUuid(name);
425
+ }
426
+
427
+ /** 名单里已有这个名字的那条(按 uuid 或原样的名字认) */
428
+ function findOp(ops: OpEntry[], name: string, uuid: string): OpEntry | undefined {
429
+ return ops.find((e) => e.uuid === uuid || e.name === name);
430
+ }
431
+
432
+ export function isOp(ops: OpEntry[], serverDir: string, name: string): OpEntry | undefined {
433
+ return findOp(ops, name, resolveUuid(serverDir, name));
434
+ }
435
+
436
+ export function grantOp(
437
+ ops: OpEntry[], serverDir: string, name: string, level = 4,
438
+ ): { ops: OpEntry[]; changed: boolean } {
439
+ const uuid = resolveUuid(serverDir, name);
440
+ const hit = findOp(ops, name, uuid);
441
+ if (hit) {
442
+ if (hit.level >= level) return { ops, changed: false };
443
+ return { ops: ops.map((e) => (e === hit ? { ...e, level } : e)), changed: true };
444
+ }
445
+ return { ops: [...ops, { uuid, name, level, bypassesPlayerLimit: false }], changed: true };
446
+ }
447
+
448
+ export function revokeOp(ops: OpEntry[], serverDir: string, name: string): { ops: OpEntry[]; changed: boolean } {
449
+ const uuid = resolveUuid(serverDir, name);
450
+ const rest = ops.filter((e) => e.uuid !== uuid && e.name !== name);
451
+ return { ops: rest, changed: rest.length !== ops.length };
452
+ }
453
+
454
+ /** 仅在服务器停止时直接修改这些文件。 */
455
+ export function ensureOps(serverDir: string, names: readonly string[], level = 4): string[] {
456
+ if (!serverDir || !existsSync(serverDir)) return [];
457
+ let ops = readOps(serverDir);
458
+ const added: string[] = [];
459
+ for (const name of names) {
460
+ if (!name.trim()) continue;
461
+ const next = grantOp(ops, serverDir, name, level);
462
+ if (next.changed) added.push(name);
463
+ ops = next.ops;
464
+ }
465
+ if (added.length) writeOps(serverDir, ops);
466
+ return added;
467
+ }