cortico-world-bloom-kpect 0.3.2-dev.v7.4 → 0.3.2-dev.v7.5

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/dist/index.js CHANGED
@@ -1,11 +1,50 @@
1
+ // bin/join-bot.mjs
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { readFile, writeFile, rename, unlink } from "node:fs/promises";
4
+ async function joinBot(file, url, name, signal, resume = false) {
5
+ if (!name || name.length > 20 || /[\u0000-\u001f\u007f]/.test(name)) throw Error("\u5C45\u6C11\u6635\u79F0\u9700\u4E3A 1\u201320 \u4E2A\u5B57\u7B26");
6
+ let credentials;
7
+ try {
8
+ credentials = JSON.parse(await readFile(file, "utf8"));
9
+ } catch (error) {
10
+ if (error.code !== "ENOENT") throw error;
11
+ credentials = { url, name, token: randomBytes(32).toString("hex"), requestId: randomUUID() };
12
+ await writeFile(file, JSON.stringify(credentials, null, 2), { flag: "wx", mode: 384 });
13
+ }
14
+ if (credentials.url !== url || credentials.name !== name) throw Error("\u51ED\u636E\u5C5E\u4E8E\u5176\u4ED6\u5C0F\u9547\u6216\u6635\u79F0\uFF0C\u8BF7\u4F7F\u7528\u539F\u5165\u9547\u6635\u79F0\u548C\u670D\u52A1\u5668");
15
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(credentials.token) || !credentials.requestId) throw Error("\u672C\u673A\u5165\u9547\u51ED\u636E\u635F\u574F");
16
+ if (resume && credentials.member?.id) return { credentials };
17
+ const response = await fetch(url + "/api/join", {
18
+ method: "POST",
19
+ redirect: "error",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify({ name, kind: "bot", clientToken: credentials.token, requestId: credentials.requestId }),
22
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(3e4)]) : AbortSignal.timeout(3e4)
23
+ });
24
+ const result = await response.json();
25
+ if (!response.ok) throw Error("\u5165\u9547\u5931\u8D25 HTTP " + response.status + " " + String(result.code ?? "").replace(/[^A-Z0-9_]/g, "").slice(0, 80));
26
+ if (!result.member?.id || result.member.kind !== "bot") throw Error("\u5165\u9547\u54CD\u5E94\u7F3A\u5C11 Bot \u8EAB\u4EFD");
27
+ credentials.member = result.member;
28
+ const temporary = file + "." + randomUUID() + ".tmp";
29
+ try {
30
+ await writeFile(temporary, JSON.stringify(credentials, null, 2), { flag: "wx", mode: 384 });
31
+ await rename(temporary, file);
32
+ } finally {
33
+ await unlink(temporary).catch((error) => {
34
+ if (error.code !== "ENOENT") throw error;
35
+ });
36
+ }
37
+ return { credentials, result };
38
+ }
39
+
1
40
  // src/index.ts
2
41
  import { join as join2 } from "node:path";
3
42
  import { fileURLToPath } from "node:url";
4
- import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
43
+ import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir2 } from "node:fs/promises";
5
44
 
6
45
  // src/transport.ts
7
46
  import { createHash } from "node:crypto";
8
- import { mkdir, readFile, writeFile, unlink, realpath } from "node:fs/promises";
47
+ import { mkdir, readFile as readFile2, writeFile as writeFile2, unlink as unlink2, realpath } from "node:fs/promises";
9
48
  import { join, resolve } from "node:path";
10
49
  var mutationLocks = /* @__PURE__ */ new Set();
11
50
  function serverUrl(input) {
@@ -52,10 +91,10 @@ var BloomTransport = class {
52
91
  await mkdir(this.dir, { recursive: true });
53
92
  const binding = join(this.dir, "server-" + createHash("sha256").update(token).digest("hex") + ".json");
54
93
  try {
55
- await writeFile(binding, JSON.stringify({ url }), { flag: "wx", mode: 384 });
94
+ await writeFile2(binding, JSON.stringify({ url }), { flag: "wx", mode: 384 });
56
95
  } catch (error) {
57
96
  if (error.code !== "EEXIST") throw error;
58
- if (JSON.parse(await readFile(binding, "utf8")).url !== url) throw Error("\u6B64Bot\u4EE4\u724C\u5DF2\u7ED1\u5B9A\u53E6\u4E00\u670D\u52A1\u5668\uFF0C\u8BF7\u4F7F\u7528\u65B0\u670D\u52A1\u5668\u72EC\u7ACB\u7B7E\u53D1\u7684\u4EE4\u724C");
97
+ if (JSON.parse(await readFile2(binding, "utf8")).url !== url) throw Error("\u6B64Bot\u4EE4\u724C\u5DF2\u7ED1\u5B9A\u53E6\u4E00\u670D\u52A1\u5668\uFF0C\u8BF7\u4F7F\u7528\u65B0\u670D\u52A1\u5668\u72EC\u7ACB\u7B7E\u53D1\u7684\u4EE4\u724C");
59
98
  }
60
99
  const response = await fetch(url + "/api/" + endpoint, { method: body ? "POST" : "GET", redirect: "error", headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, ...body ? { body: JSON.stringify(body) } : {}, signal: AbortSignal.any([AbortSignal.timeout(2e4), ...signal ? [signal] : []]) }).catch(() => {
61
100
  throw Error("\u82B1\u8BED\u4E61\u8FDE\u63A5\u4E2D\u65AD\u6216\u8D85\u65F6\uFF1B\u5DF2\u63D0\u4EA4\u52A8\u4F5C\u7684\u7ED3\u679C\u53EF\u80FD\u672A\u77E5\uFF0C\u8BF7\u4FDD\u7559\u539FrequestId");
@@ -88,7 +127,7 @@ var BloomTransport = class {
88
127
  }
89
128
  async pending() {
90
129
  try {
91
- return JSON.parse(await readFile(join(this.dir, "pending.json"), "utf8"));
130
+ return JSON.parse(await readFile2(join(this.dir, "pending.json"), "utf8"));
92
131
  } catch (e) {
93
132
  if (e.code === "ENOENT") return null;
94
133
  throw Error("\u672A\u89E3\u51B3\u8BF7\u6C42\u8BB0\u5F55\u635F\u574F\uFF0C\u8BF7\u64CD\u4F5C\u8005\u6838\u67E5\uFF0C\u4E0D\u81EA\u52A8\u8986\u76D6");
@@ -106,13 +145,13 @@ var BloomTransport = class {
106
145
  const identity = this.identity().key, old = await this.pending(), payload = JSON.stringify({ endpoint, body });
107
146
  if (old && (old.identity !== identity || old.payload !== payload)) throw Error("\u6709\u672A\u89E3\u51B3\u52A8\u4F5C\uFF0C\u8BF7\u5148\u7528\u539FrequestId\u548C\u76F8\u540C\u53C2\u6570\u91CD\u8BD5\uFF1B\u4E0D\u80FD\u6362\u670D\u52A1\u5668\u3001\u4EE4\u724C\u6216\u8BF7\u6C42\u7ED5\u8FC7");
108
147
  if (signal?.aborted) throw Error("\u8BF7\u6C42\u5DF2\u53D6\u6D88\uFF0C\u5C1A\u672A\u63D0\u4EA4");
109
- if (!old) await writeFile(join(this.dir, "pending.json"), JSON.stringify({ identity, payload, requestId: body.requestId }), { flag: "wx", mode: 384 });
148
+ if (!old) await writeFile2(join(this.dir, "pending.json"), JSON.stringify({ identity, payload, requestId: body.requestId }), { flag: "wx", mode: 384 });
110
149
  try {
111
150
  const result = await this.request(endpoint, body, signal, identity);
112
- await unlink(join(this.dir, "pending.json"));
151
+ await unlink2(join(this.dir, "pending.json"));
113
152
  return result;
114
153
  } catch (error) {
115
- if (error instanceof BloomHttpError && error.status >= 400 && error.status < 500 && error.code !== "CONCURRENT_UPDATE") await unlink(join(this.dir, "pending.json"));
154
+ if (error instanceof BloomHttpError && error.status >= 400 && error.status < 500 && error.code !== "CONCURRENT_UPDATE") await unlink2(join(this.dir, "pending.json"));
116
155
  throw error;
117
156
  }
118
157
  } finally {
@@ -168,9 +207,12 @@ function decisionSummary(state, now = Date.now()) {
168
207
  if (Array.isArray(state.jobs)) {
169
208
  const known = state.jobs.filter((job) => finite(job.remaining) && finite(job.fundedRemaining) && finite(job.reward) && finite(job.energy) && typeof job.done === "boolean");
170
209
  const incomeRemaining = state.economyV7?.incomeRemaining;
171
- const candidates = known.filter((job) => !state.activeAction && finite(me.energy) && me.energy >= job.energy && (!finite(incomeRemaining) || job.reward <= incomeRemaining) && !job.done && !job.unavailableReason && job.remaining > 0 && job.fundedRemaining > 0);
172
- const ready = candidates.filter((job) => job.location === me.location), travel = candidates.filter((job) => job.location !== me.location);
210
+ const shiftsLeft = Math.max(0, 3 - known.filter((job) => job.done).length);
211
+ const candidates = known.filter((job) => shiftsLeft > 0 && !job.signedUp && !state.activeAction && finite(me.energy) && me.energy >= job.energy && (!finite(incomeRemaining) || job.reward <= incomeRemaining) && !job.done && !job.unavailableReason && job.remaining > 0 && job.fundedRemaining > 0);
212
+ const ready = candidates.filter((job) => job.location === me.location && job.available !== false), travel = candidates.filter((job) => job.location !== me.location);
173
213
  out.work = { readyHere: ready.map((job) => job.id), needsTravel: travel.map((job) => job.id), sharedBudget: true };
214
+ const signup = known.find((job) => job.signedUp);
215
+ if (signup) out.work.waitingForPartner = { jobId: signup.id, cancelAction: "cancelTeamWork" };
174
216
  if (finite(incomeRemaining)) out.work.incomeRemaining = incomeRemaining;
175
217
  if (known.length !== state.jobs.length || !finite(me.energy)) out.work.needsCheck = true;
176
218
  if (finite(state.economy?.workRemaining)) {
@@ -182,7 +224,7 @@ function decisionSummary(state, now = Date.now()) {
182
224
  spent += job.reward;
183
225
  count++;
184
226
  }
185
- out.work.affordableTypesUpperBound = count;
227
+ out.work.affordableTypesUpperBound = Math.min(count, shiftsLeft);
186
228
  }
187
229
  parts.push(`\u8FD9\u91CC\u53EF\u63A5${ready.length}\u7C7B\u5DE5\u4F5C\uFF0C\u6362\u5730\u70B9\u53EF\u8003\u8651${travel.length}\u7C7B\uFF08\u5171\u7528\u5DE5\u8D44\u6C60\uFF0C\u540D\u989D\u4E0D\u76F8\u52A0${out.work.needsCheck ? "\uFF1B\u90E8\u5206\u4FE1\u606F\u5F85\u67E5" : ""}\uFF09`);
188
230
  }
@@ -364,9 +406,10 @@ var StateNotifications = class {
364
406
  };
365
407
 
366
408
  // src/index.ts
367
- var defaults = () => ({ enabled: false, serverUrl: "", tokenEnvName: "CORTICO_BLOOM_TOKEN", allowActions: false, allowChat: false, receivePrivateMessages: false, pollIntervalSec: 60, stateEventIntervalSec: 300, stateWakeMode: "important", watchChannels: "town,lounge,notice" });
409
+ var defaults = () => ({ enabled: false, serverUrl: "", tokenEnvName: "CORTICO_BLOOM_TOKEN", registrationName: "", allowActions: false, allowChat: false, receivePrivateMessages: false, pollIntervalSec: 60, stateEventIntervalSec: 300, stateWakeMode: "important", watchChannels: "town,lounge,notice" });
368
410
  var config = { id: "world:bloom", owner: "world:bloom", schema: { type: "object", title: "\u82B1\u8BED\u4E61\u8FDE\u63A5\u4E0E\u81EA\u4E3B\u6743\u9650", properties: {
369
411
  "worlds.bloom.serverUrl": { type: "string", title: "\u82B1\u8BED\u4E61\u670D\u52A1\u5668\u5730\u5740", description: "\u8FDC\u7A0B\u4F7F\u7528HTTPS\uFF1B\u672C\u673A\u53EF\u7528http://127.0.0.1:4173\u3002\u4E0D\u5F97\u586B\u5199\u542B\u4EE4\u724C\u7684URL\u3002" },
412
+ "worlds.bloom.registrationName": { type: "string", title: "\u65B0 Bot \u5165\u9547\u6635\u79F0", description: "\u65B0 Bot \u586B\u5199\u6635\u79F0\u540E\u542F\u7528\u5373\u53EF\u6CE8\u518C\u3002\u51ED\u636E\u4FDD\u5B58\u5728\u5F53\u524D Cortico \u6570\u636E\u76EE\u5F55\uFF1B\u91CD\u542F\u6CBF\u7528\u539F\u6635\u79F0\u3002\u5DF2\u6709\u73AF\u5883\u4EE4\u724C\u4F18\u5148\u3002" },
370
413
  "worlds.bloom.tokenEnvName": { type: "string", title: "Bot\u4EE4\u724C\u73AF\u5883\u53D8\u91CF\u540D", description: "\u586B\u5199\u53D8\u91CF\u540D\uFF1B\u4EE4\u724C\u503C\u4FDD\u5B58\u5728\u90E8\u7F72.env\u4E2D\u3002\u6BCF\u4E2ABot\u4F7F\u7528\u72EC\u7ACB\u4EE4\u724C\u3002" },
371
414
  "worlds.bloom.allowActions": { type: "boolean", title: "\u5141\u8BB8\u81EA\u4E3B\u6E38\u620F\u52A8\u4F5C", description: "\u4ECD\u53D7\u670D\u52A1\u5668\u5F53\u524D\u6743\u9650\u3001\u6D88\u8D39\u3001\u8D60\u793C\u548C\u635F\u5931\u989D\u5EA6\u9650\u5236\u3002" },
372
415
  "worlds.bloom.allowChat": { type: "boolean", title: "\u5141\u8BB8\u81EA\u4E3B\u6E38\u620F\u5185\u53D1\u8A00" },
@@ -379,7 +422,7 @@ var config = { id: "world:bloom", owner: "world:bloom", schema: { type: "object"
379
422
  var BloomWorld = class {
380
423
  constructor(ctx) {
381
424
  this.ctx = ctx;
382
- this.transport = new BloomTransport(() => ({ url: ctx.cfg.serverUrl, token: ctx.secret(ctx.cfg.tokenEnvName) }), join2(ctx.dataDir, "bloom-transport"));
425
+ this.transport = new BloomTransport(() => this.connection(), join2(ctx.dataDir, "bloom-transport"));
383
426
  }
384
427
  ctx;
385
428
  id = "bloom";
@@ -404,6 +447,23 @@ var BloomWorld = class {
404
447
  tableSubscriptions = /* @__PURE__ */ new Set();
405
448
  receivingPrivateMessages = false;
406
449
  privateSince = 0;
450
+ localBot;
451
+ connection() {
452
+ const { serverUrl: url, tokenEnvName, registrationName } = this.ctx.cfg;
453
+ const local = this.localBot;
454
+ const token = this.ctx.secret(tokenEnvName) || (local && local.url === serverUrl(url) && local.name === registrationName?.trim() ? local.token : "");
455
+ return { url, token };
456
+ }
457
+ async prepareRegistration(signal) {
458
+ if (this.connection().token) return;
459
+ const name = this.ctx.cfg.registrationName?.trim();
460
+ if (!name) throw Error("\u65B0 Bot \u8BF7\u586B\u5199\u5165\u9547\u6635\u79F0\uFF1B\u5DF2\u6709\u5C45\u6C11\u8BF7\u914D\u7F6E\u672C\u4EBA\u7684\u4EE4\u724C");
461
+ const url = serverUrl(this.ctx.cfg.serverUrl), dir = join2(this.ctx.dataDir, "bloom-transport");
462
+ await mkdir2(dir, { recursive: true });
463
+ const { credentials } = await joinBot(join2(dir, "registration.json"), url, name, signal, true);
464
+ signal.throwIfAborted();
465
+ this.localBot = { url, name, token: credentials.token };
466
+ }
407
467
  envPromptVars() {
408
468
  return { "bloom.permissions": `\u6E38\u620F\u52A8\u4F5C${this.ctx.cfg.allowActions ? "\u5141\u8BB8" : "\u6682\u505C"}\uFF0C\u6E38\u620F\u5185\u53D1\u8A00${this.ctx.cfg.allowChat ? "\u5141\u8BB8" : "\u6682\u505C"}\uFF0C\u63A5\u6536\u79C1\u4FE1${this.ctx.cfg.receivePrivateMessages ? "\u5F00\u542F" : "\u5173\u95ED"}\uFF1B\u8BF7\u67E5\u8BE2\u5B9E\u65F6\u72B6\u6001` };
409
469
  }
@@ -494,6 +554,7 @@ var BloomWorld = class {
494
554
  }
495
555
  };
496
556
  try {
557
+ if (!this.connection().token) await this.prepareRegistration(signal);
497
558
  key = this.transport.identity().key;
498
559
  const freshState = () => fresh() && revision === this.observationRevision;
499
560
  const state = await this.observe(signal);
@@ -504,7 +565,7 @@ var BloomWorld = class {
504
565
  if (!freshState()) return;
505
566
  let cursors = {};
506
567
  try {
507
- cursors = JSON.parse(await readFile2(join2(this.ctx.dataDir, "bloom-transport", "cursors-" + key + ".json"), "utf8"));
568
+ cursors = JSON.parse(await readFile3(join2(this.ctx.dataDir, "bloom-transport", "cursors-" + key + ".json"), "utf8"));
508
569
  } catch (e) {
509
570
  if (e.code !== "ENOENT") throw Error("\u9891\u9053\u6E38\u6807\u635F\u574F\uFF0C\u8BF7\u64CD\u4F5C\u8005\u68C0\u67E5");
510
571
  }
@@ -616,7 +677,7 @@ var BloomWorld = class {
616
677
  if (!fresh()) return;
617
678
  await mkdir2(join2(this.ctx.dataDir, "bloom-transport"), { recursive: true });
618
679
  if (!fresh()) return;
619
- await writeFile2(join2(this.ctx.dataDir, "bloom-transport", "cursors-" + key + ".json"), cursorData, { mode: 384 });
680
+ await writeFile3(join2(this.ctx.dataDir, "bloom-transport", "cursors-" + key + ".json"), cursorData, { mode: 384 });
620
681
  });
621
682
  await this.cursorWrites;
622
683
  if (!fresh()) return;
@@ -624,11 +685,12 @@ var BloomWorld = class {
624
685
  if (!fresh()) return;
625
686
  this.error = "";
626
687
  this.failures = 0;
627
- } catch {
688
+ } catch (error) {
628
689
  if (!this.current(generation) || signal.aborted || revision !== this.observationRevision) return;
629
690
  this.failures++;
630
- if (this.error !== "\u82B1\u8BED\u4E61\u89C2\u5BDF\u5931\u8D25\uFF1B\u8BF7\u67E5\u8BE2\u72B6\u6001\u5DE5\u5177\u6838\u5BF9\u670D\u52A1\u5668\u3001\u4EE4\u724C\u6216\u9891\u9053\u6743\u9650") {
631
- this.error = "\u82B1\u8BED\u4E61\u89C2\u5BDF\u5931\u8D25\uFF1B\u8BF7\u67E5\u8BE2\u72B6\u6001\u5DE5\u5177\u6838\u5BF9\u670D\u52A1\u5668\u3001\u4EE4\u724C\u6216\u9891\u9053\u6743\u9650";
691
+ const detail = !key && error instanceof Error && /^(新 Bot 请填写|居民昵称需为|入镇失败 HTTP|凭据属于|本机入镇凭据损坏)/.test(error.message) ? error.message : "\u82B1\u8BED\u4E61\u89C2\u5BDF\u5931\u8D25\uFF1B\u8BF7\u67E5\u8BE2\u72B6\u6001\u5DE5\u5177\u6838\u5BF9\u670D\u52A1\u5668\u3001\u4EE4\u724C\u6216\u9891\u9053\u6743\u9650";
692
+ if (this.error !== detail) {
693
+ this.error = detail;
632
694
  try {
633
695
  await this.event("connection", { connected: false, error: this.error }, generation);
634
696
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-bloom-kpect",
3
- "version": "0.3.2-dev.v7.4",
3
+ "version": "0.3.2-dev.v7.5",
4
4
  "description": "花语乡 Project Bloom(群 1132517396):让 Cortico Bot 自主生活、交易、送礼与交流的 World 扩展",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {joinBot} from '../bin/join-bot.mjs';
1
2
  import {join} from 'node:path';
2
3
  import {fileURLToPath} from 'node:url';
3
4
  import {readFile,writeFile,mkdir} from 'node:fs/promises';
@@ -10,10 +11,11 @@ export {StateNotifications} from './state-notifications';
10
11
  export {BloomTransport,BloomHttpError,serverUrl} from './transport';
11
12
  export {selectState,eventVersion,stateEvent,compactReceipt} from './observation';
12
13
 
13
- export type BloomConfig={enabled:boolean;serverUrl:string;tokenEnvName:string;allowActions:boolean;allowChat:boolean;receivePrivateMessages:boolean;pollIntervalSec:number;stateEventIntervalSec:number;stateWakeMode:'important'|'all'|'off';watchChannels:string};
14
- export const defaults=():BloomConfig=>({enabled:false,serverUrl:'',tokenEnvName:'CORTICO_BLOOM_TOKEN',allowActions:false,allowChat:false,receivePrivateMessages:false,pollIntervalSec:60,stateEventIntervalSec:300,stateWakeMode:'important',watchChannels:'town,lounge,notice'});
14
+ export type BloomConfig={enabled:boolean;serverUrl:string;tokenEnvName:string;registrationName?:string;allowActions:boolean;allowChat:boolean;receivePrivateMessages:boolean;pollIntervalSec:number;stateEventIntervalSec:number;stateWakeMode:'important'|'all'|'off';watchChannels:string};
15
+ export const defaults=():BloomConfig=>({enabled:false,serverUrl:'',tokenEnvName:'CORTICO_BLOOM_TOKEN',registrationName:'',allowActions:false,allowChat:false,receivePrivateMessages:false,pollIntervalSec:60,stateEventIntervalSec:300,stateWakeMode:'important',watchChannels:'town,lounge,notice'});
15
16
  const config:ConfigGroup={id:'world:bloom',owner:'world:bloom',schema:{type:'object',title:'花语乡连接与自主权限',properties:{
16
17
  'worlds.bloom.serverUrl':{type:'string',title:'花语乡服务器地址',description:'远程使用HTTPS;本机可用http://127.0.0.1:4173。不得填写含令牌的URL。'},
18
+ 'worlds.bloom.registrationName':{type:'string',title:'新 Bot 入镇昵称',description:'新 Bot 填写昵称后启用即可注册。凭据保存在当前 Cortico 数据目录;重启沿用原昵称。已有环境令牌优先。'},
17
19
  'worlds.bloom.tokenEnvName':{type:'string',title:'Bot令牌环境变量名',description:'填写变量名;令牌值保存在部署.env中。每个Bot使用独立令牌。'},
18
20
  'worlds.bloom.allowActions':{type:'boolean',title:'允许自主游戏动作',description:'仍受服务器当前权限、消费、赠礼和损失额度限制。'},
19
21
  'worlds.bloom.allowChat':{type:'boolean',title:'允许自主游戏内发言'},
@@ -31,7 +33,24 @@ export class BloomWorld implements World{
31
33
  private prompts=new Map<string,{revision:number;sent:number}>();
32
34
  private tableSubscriptions=new Set<string>();
33
35
  private receivingPrivateMessages=false;private privateSince=0;
34
- constructor(private ctx:WorldContext<BloomConfig>){this.transport=new BloomTransport(()=>({url:ctx.cfg.serverUrl,token:ctx.secret(ctx.cfg.tokenEnvName)}),join(ctx.dataDir,'bloom-transport'));}
36
+ private localBot?:{url:string;name:string;token:string};
37
+ private connection(){
38
+ const {serverUrl:url,tokenEnvName,registrationName}=this.ctx.cfg;
39
+ const local=this.localBot;
40
+ const token=this.ctx.secret(tokenEnvName)||(local&&local.url===serverUrl(url)&&local.name===registrationName?.trim()?local.token:'');
41
+ return {url,token};
42
+ }
43
+ private async prepareRegistration(signal:AbortSignal){
44
+ if(this.connection().token)return;
45
+ const name=this.ctx.cfg.registrationName?.trim();
46
+ if(!name)throw Error('新 Bot 请填写入镇昵称;已有居民请配置本人的令牌');
47
+ const url=serverUrl(this.ctx.cfg.serverUrl),dir=join(this.ctx.dataDir,'bloom-transport');
48
+ await mkdir(dir,{recursive:true});
49
+ const {credentials}=await joinBot(join(dir,'registration.json'),url,name,signal,true);
50
+ signal.throwIfAborted();
51
+ this.localBot={url,name,token:credentials.token};
52
+ }
53
+ constructor(private ctx:WorldContext<BloomConfig>){this.transport=new BloomTransport(()=>this.connection(),join(ctx.dataDir,'bloom-transport'));}
35
54
  envPromptVars(){return {'bloom.permissions':`游戏动作${this.ctx.cfg.allowActions?'允许':'暂停'},游戏内发言${this.ctx.cfg.allowChat?'允许':'暂停'},接收私信${this.ctx.cfg.receivePrivateMessages?'开启':'关闭'};请查询实时状态`};}
36
55
  webUi(){return {label:'花语乡 Project Bloom',config:[config],lamps:[{label:'花语乡连接',state:this.error?'error' as const:this.fingerprint?'online' as const:'offline' as const,detail:this.error||'通过观察工具查询当前连接'}],promptDocs:[{key:'worlds.bloom.envPrompt',title:'花语乡生活与安全',description:'状态、交易、赠礼和不可信消息边界',path:fileURLToPath(new URL('../ENV_PROMPT.md',import.meta.url)),role:'envPrompt' as const,vars:[{name:'bloom.permissions',description:'实时自主权限'}]}]};}
37
56
  private current(generation:number){return !this.stopped&&generation===this.generation;}
@@ -76,6 +95,7 @@ export class BloomWorld implements World{
76
95
  let key='';
77
96
  const fresh=()=>{if(!this.current(generation)||signal.aborted)return false;try{return this.transport.identity().key===key;}catch{return false;}};
78
97
  try{
98
+ if(!this.connection().token)await this.prepareRegistration(signal);
79
99
  key=this.transport.identity().key;
80
100
  const freshState=()=>fresh()&&revision===this.observationRevision;
81
101
  const state=await this.observe(signal);if(!freshState())return;
@@ -164,11 +184,12 @@ export class BloomWorld implements World{
164
184
  await this.cursorWrites;if(!fresh())return;
165
185
  if(this.error)await this.event('connection',{connected:true},generation);
166
186
  if(!fresh())return;this.error='';this.failures=0;
167
- }catch{
187
+ }catch(error){
168
188
  if(!this.current(generation)||signal.aborted||revision!==this.observationRevision)return;
169
189
  this.failures++;
170
- if(this.error!=='花语乡观察失败;请查询状态工具核对服务器、令牌或频道权限'){
171
- this.error='花语乡观察失败;请查询状态工具核对服务器、令牌或频道权限';
190
+ const detail=!key&&error instanceof Error&&/^(新 Bot 请填写|居民昵称需为|入镇失败 HTTP|凭据属于|本机入镇凭据损坏)/.test(error.message)?error.message:'花语乡观察失败;请查询状态工具核对服务器、令牌或频道权限';
191
+ if(this.error!==detail){
192
+ this.error=detail;
172
193
  try{await this.event('connection',{connected:false,error:this.error},generation);}catch{/* A host rejection must never escape a background poll. */}
173
194
  }
174
195
  }
@@ -1,82 +1,84 @@
1
- // Projection happens only after the authenticated server has filtered the resident view.
2
- type State=Record<string,any>;
3
- export const coreSections=['clock','me','activeAction','onboarding','plots','claims','botSafety','contentPolicy','quiz','parlor','economyV7','marketRegulation','whale'] as const;
4
- const legacySections=['clock','me','activeAction','onboarding','plots','claims','availableActions','nearby','jobs','neighbors','home','life','leisure','quiz','parlor','porch','seasonal','belongings','crafting','economy','custody','housing','trade','orders','botSafety','contentPolicy'];
5
- export const stateSections=[...new Set([...legacySections,'economyV7','marketRegulation','whale','careers','chat','titles','shops','locations','residents','catalog','recipes','market','mailbox','visits','visitingHome','projects','caravan','registeredEvents','chronicle','donation','television','houses','ledger','activity','finance','publicBudget','publicSpending','buybackPolicies','todayRecommendation','npcReplies','observer','observedResidentId','pendingFeatures'])];
6
- function normalized(state:State):State{return {...state,nearby:state.locations?.find((l:{id:string})=>l.id===state.me?.location),neighbors:state.residents};}
7
- function pick(state:State,keys:readonly string[]){return Object.fromEntries(keys.filter(key=>state[key]!==undefined).map(key=>[key,state[key]]));}
8
- function compactEconomy(value:State){return {...pick(value,['day','earnedToday','incomeRemaining','pendingIncome','publicCustody','custodyProtectedUntil','nextIncomeResetAt']),policy:pick(value.policy??{},['dailyIncomeLimit','idleWalletThreshold','idleAfterMs'])};}
9
- function compactMarket(value:State){return {...pick(value,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),...(value.resources?{resources:{location:value.resources.location,availableKinds:value.resources.items?.filter((x:State)=>x.remaining>0).length??0,totalKinds:value.resources.items?.length??0,nextRefillAt:value.resources.nextRefillAt}}:{})};}
10
- function compactWhale(value:State){
11
- if(!value.joined)return pick(value,['name','joined','open','initialCash']);
12
- const me=value.me??{},prizes=me.prizes??[];
13
- return {...pick(value,['name','joined','open','currency','catchingUp','weekEndsAt']),rules:pick(value.rules??{},['dailyLimitPercent','feePercent','tradesPerHour','minWeeklyTrades','minWeeklyDays']),stocks:value.stocks?.map((s:State)=>pick(s,['id','price','changePercent'])),me:{...pick(me,['cash','netWorth','rank','holdings','tradesThisWeek','daysThisWeek','tradesRemainingThisHour']),prizes:[...prizes.filter((p:State)=>p.rank===1&&!p.claimedTitle),...prizes.filter((p:State)=>p.claimedTitle).slice(-3)]}};
14
- }
15
- function summaries(source:State){return {...(source.economyV7!==undefined?{economyV7:source.economyV7===null?null:compactEconomy(source.economyV7)}:{}),...(source.marketRegulation!==undefined?{marketRegulation:source.marketRegulation===null?null:compactMarket(source.marketRegulation)}:{}),...(source.whale!==undefined?{whale:source.whale===null?null:compactWhale(source.whale)}:{})};}
16
- const finite=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value);
17
- /** Decision hints use server fields only and never participate in eventVersion. */
18
- export function decisionSummary(state:State,now=Date.now()):State{
19
- const me=state.me??{},parts:string[]=[],out:State={};
20
- if(typeof state.clock?.weather==='string'){out.weather=state.clock.weather;parts.push(`天气${state.clock.weather}`);}
21
- if(finite(me.energy)){out.energy=me.energy;parts.push(`体力${me.energy}`);}
22
- if(state.activeAction){out.busy=pick(state.activeAction,['id','type','endsAt','finishAt']);parts.push('当前有进行中的动作');}
23
- if(Array.isArray(state.plots)){
24
- const ready=state.plots.filter((plot:State)=>plot.crop&&!plot.busy&&(plot.mature===true||finite(plot.maturesAt)&&plot.maturesAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now)));
25
- out.crops={ready:ready.length,plotIds:ready.map((plot:State)=>plot.id).filter((id:unknown)=>typeof id==='string')};parts.push(`成熟待收${ready.length}块`);
26
- }
27
- if(Array.isArray(state.jobs)){
28
- const known=state.jobs.filter((job:State)=>finite(job.remaining)&&finite(job.fundedRemaining)&&finite(job.reward)&&finite(job.energy)&&typeof job.done==='boolean');
29
- const incomeRemaining=state.economyV7?.incomeRemaining;
30
- const candidates=known.filter((job:State)=>!state.activeAction&&finite(me.energy)&&me.energy>=job.energy&&(!finite(incomeRemaining)||job.reward<=incomeRemaining)&&!job.done&&!job.unavailableReason&&job.remaining>0&&job.fundedRemaining>0);
31
- const ready=candidates.filter((job:State)=>job.location===me.location),travel=candidates.filter((job:State)=>job.location!==me.location);
32
- out.work={readyHere:ready.map((job:State)=>job.id),needsTravel:travel.map((job:State)=>job.id),sharedBudget:true};
33
- if(finite(incomeRemaining))out.work.incomeRemaining=incomeRemaining;
34
- if(known.length!==state.jobs.length||!finite(me.energy))out.work.needsCheck=true;
35
- if(finite(state.economy?.workRemaining)){
36
- const funds=Math.max(0,state.economy.workRemaining);out.work.sharedFunds=funds;
37
- let spent=0,count=0;
38
- for(const job of [...candidates].sort((a:State,b:State)=>a.reward-b.reward)){if(job.reward<0||spent+job.reward>funds||finite(incomeRemaining)&&spent+job.reward>incomeRemaining)continue;spent+=job.reward;count++;}
39
- out.work.affordableTypesUpperBound=count;
40
- }
41
- parts.push(`这里可接${ready.length}类工作,换地点可考虑${travel.length}类(共用工资池,名额不相加${out.work.needsCheck?';部分信息待查':''})`);
42
- }
43
- if(state.housing||Array.isArray(state.houses)){
44
- const home=state.houses?.find((house:State)=>house.id===me.homeId),rent:State={};
45
- if(Array.isArray(state.housing?.invoices)){
46
- const unpaid=state.housing.invoices.filter((bill:State)=>bill.status==='UNPAID');rent.unpaidInvoices=unpaid.length;
47
- if(unpaid.every((bill:State)=>finite(bill.amount)&&bill.amount>=0))rent.unpaidAmount=unpaid.reduce((sum:number,bill:State)=>sum+bill.amount,0);
48
- }
49
- if(home?.tenantId===me.id&&!home.ownerId){if(finite(home.rent))rent.amount=home.rent;if(finite(home.leaseDueAt)&&Math.abs(home.leaseDueAt)<=8640000000000000)rent.nextDueAt=home.leaseDueAt;if(finite(home.graceAt))rent.graceStartedAt=home.graceAt;}
50
- if(Object.keys(rent).length){
51
- out.rent=rent;if(rent.unpaidAmount>0)parts.push(`未付租金${rent.unpaidAmount}花币`);
52
- if(rent.nextDueAt!==undefined){rent.due=rent.nextDueAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now);parts.push(`${rent.due?'租金账期已到':'下次租金'}${new Date(rent.nextDueAt).toISOString()}`);}
53
- }
54
- }
55
- return {summary:parts.join(';'),...out};
56
- }
57
- export function selectState(state:State,sections?:unknown):State{
58
- const source=normalized(state);
59
- if(sections===undefined)return {...pick(source,legacySections),...summaries(source)}; // Keep old unparameterized clients compatible.
60
- if(!Array.isArray(sections)||!sections.length||sections.length>stateSections.length+2||sections.some(s=>typeof s!=='string'||!['core','full',...stateSections].includes(s)))throw Error('sections须为有效状态章节数组');
61
- if(sections.includes('full'))return source;
62
- const keys=[...new Set(sections.flatMap(s=>s==='core'?[...coreSections]:[s]))];
63
- const projection=pick(source,keys);
64
- // Explicit chapters override their core summary, independent of requested order.
65
- if(sections.includes('core'))for(const [key,value]of Object.entries(summaries(source)))if(!sections.includes(key))projection[key]=value;
66
- return {...projection,...(sections.includes('core')?{decisionSummary:decisionSummary(source)}:{}),sectionsAvailable:stateSections.filter(key=>source[key]!==undefined),view:'selected',requestedSections:sections};
67
- }
68
- export function compactReceipt(result:State):State{return result?.state?.me?{...result,state:selectState(result.state,['core'])}:result;}
69
- // Server clock ticks do not represent decisions. Crop maturity and turn transitions do.
70
- export function eventVersion(state:State,now=Date.now()):State{
71
- const short=summaries(state),changes:State={};
72
- if(short.economyV7!==undefined)changes.economyV7=short.economyV7===null?null:pick(short.economyV7,['policy','earnedToday','incomeRemaining','pendingIncome','publicCustody']);
73
- if(short.marketRegulation!==undefined){const market=short.marketRegulation;changes.marketRegulation=market===null?null:{...pick(market,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),resources:pick(market.resources??{},['location','availableKinds','totalKinds'])};}
74
- if(short.whale!==undefined)changes.whale=short.whale===null?null:pick(short.whale,['joined','open','catchingUp','rules','stocks','me']);
75
- return {...pick(state,['me','chat','onboarding','activeAction','claims','botSafety','quiz','parlor','orders','trade','custody','shops','mailbox','visits','projects']),...changes,plots:state.plots?.map((p:{maturesAt?:number})=>({...p,mature:typeof p.maturesAt==='number'&&p.maturesAt<=now})),clock:pick(state.clock??{},['day','season','weather'])};
76
- }
77
- export function stateEvent(state:State,previous:State|null,version:State){
78
- const changedSections=Object.keys(version).filter(key=>!previous||JSON.stringify(version[key])!==JSON.stringify(previous[key]));
79
- if(!changedSections.length)return null;
80
- const core=selectState(state,['core']);
81
- return {trust:'server-state-with-untrusted-user-fields',mode:previous?'changes':'snapshot',changedSections,state:previous?{...pick(core,changedSections),sectionsAvailable:core.sectionsAvailable}:core,read:'Read changedSections with bloom_state({sections:["core",...changedSections]}) when needed. Omitted payloads do not mean empty or unchanged.'};
82
- }
1
+ // Projection happens only after the authenticated server has filtered the resident view.
2
+ type State=Record<string,any>;
3
+ export const coreSections=['clock','me','activeAction','onboarding','plots','claims','botSafety','contentPolicy','quiz','parlor','economyV7','marketRegulation','whale'] as const;
4
+ const legacySections=['clock','me','activeAction','onboarding','plots','claims','availableActions','nearby','jobs','neighbors','home','life','leisure','quiz','parlor','porch','seasonal','belongings','crafting','economy','custody','housing','trade','orders','botSafety','contentPolicy'];
5
+ export const stateSections=[...new Set([...legacySections,'economyV7','marketRegulation','whale','careers','chat','titles','shops','locations','residents','catalog','recipes','market','mailbox','visits','visitingHome','projects','caravan','registeredEvents','chronicle','donation','television','houses','ledger','activity','finance','publicBudget','publicSpending','buybackPolicies','todayRecommendation','npcReplies','observer','observedResidentId','pendingFeatures'])];
6
+ function normalized(state:State):State{return {...state,nearby:state.locations?.find((l:{id:string})=>l.id===state.me?.location),neighbors:state.residents};}
7
+ function pick(state:State,keys:readonly string[]){return Object.fromEntries(keys.filter(key=>state[key]!==undefined).map(key=>[key,state[key]]));}
8
+ function compactEconomy(value:State){return {...pick(value,['day','earnedToday','incomeRemaining','pendingIncome','publicCustody','custodyProtectedUntil','nextIncomeResetAt']),policy:pick(value.policy??{},['dailyIncomeLimit','idleWalletThreshold','idleAfterMs'])};}
9
+ function compactMarket(value:State){return {...pick(value,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),...(value.resources?{resources:{location:value.resources.location,availableKinds:value.resources.items?.filter((x:State)=>x.remaining>0).length??0,totalKinds:value.resources.items?.length??0,nextRefillAt:value.resources.nextRefillAt}}:{})};}
10
+ function compactWhale(value:State){
11
+ if(!value.joined)return pick(value,['name','joined','open','initialCash']);
12
+ const me=value.me??{},prizes=me.prizes??[];
13
+ return {...pick(value,['name','joined','open','currency','catchingUp','weekEndsAt']),rules:pick(value.rules??{},['dailyLimitPercent','feePercent','tradesPerHour','minWeeklyTrades','minWeeklyDays']),stocks:value.stocks?.map((s:State)=>pick(s,['id','price','changePercent'])),me:{...pick(me,['cash','netWorth','rank','holdings','tradesThisWeek','daysThisWeek','tradesRemainingThisHour']),prizes:[...prizes.filter((p:State)=>p.rank===1&&!p.claimedTitle),...prizes.filter((p:State)=>p.claimedTitle).slice(-3)]}};
14
+ }
15
+ function summaries(source:State){return {...(source.economyV7!==undefined?{economyV7:source.economyV7===null?null:compactEconomy(source.economyV7)}:{}),...(source.marketRegulation!==undefined?{marketRegulation:source.marketRegulation===null?null:compactMarket(source.marketRegulation)}:{}),...(source.whale!==undefined?{whale:source.whale===null?null:compactWhale(source.whale)}:{})};}
16
+ const finite=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value);
17
+ /** Decision hints use server fields only and never participate in eventVersion. */
18
+ export function decisionSummary(state:State,now=Date.now()):State{
19
+ const me=state.me??{},parts:string[]=[],out:State={};
20
+ if(typeof state.clock?.weather==='string'){out.weather=state.clock.weather;parts.push(`天气${state.clock.weather}`);}
21
+ if(finite(me.energy)){out.energy=me.energy;parts.push(`体力${me.energy}`);}
22
+ if(state.activeAction){out.busy=pick(state.activeAction,['id','type','endsAt','finishAt']);parts.push('当前有进行中的动作');}
23
+ if(Array.isArray(state.plots)){
24
+ const ready=state.plots.filter((plot:State)=>plot.crop&&!plot.busy&&(plot.mature===true||finite(plot.maturesAt)&&plot.maturesAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now)));
25
+ out.crops={ready:ready.length,plotIds:ready.map((plot:State)=>plot.id).filter((id:unknown)=>typeof id==='string')};parts.push(`成熟待收${ready.length}块`);
26
+ }
27
+ if(Array.isArray(state.jobs)){
28
+ const known=state.jobs.filter((job:State)=>finite(job.remaining)&&finite(job.fundedRemaining)&&finite(job.reward)&&finite(job.energy)&&typeof job.done==='boolean');
29
+ const incomeRemaining=state.economyV7?.incomeRemaining;
30
+ const shiftsLeft=Math.max(0,3-known.filter((job:State)=>job.done).length);
31
+ const candidates=known.filter((job:State)=>shiftsLeft>0&&!job.signedUp&&!state.activeAction&&finite(me.energy)&&me.energy>=job.energy&&(!finite(incomeRemaining)||job.reward<=incomeRemaining)&&!job.done&&!job.unavailableReason&&job.remaining>0&&job.fundedRemaining>0);
32
+ const ready=candidates.filter((job:State)=>job.location===me.location&&job.available!==false),travel=candidates.filter((job:State)=>job.location!==me.location);
33
+ out.work={readyHere:ready.map((job:State)=>job.id),needsTravel:travel.map((job:State)=>job.id),sharedBudget:true};
34
+ const signup=known.find((job:State)=>job.signedUp);if(signup)out.work.waitingForPartner={jobId:signup.id,cancelAction:'cancelTeamWork'};
35
+ if(finite(incomeRemaining))out.work.incomeRemaining=incomeRemaining;
36
+ if(known.length!==state.jobs.length||!finite(me.energy))out.work.needsCheck=true;
37
+ if(finite(state.economy?.workRemaining)){
38
+ const funds=Math.max(0,state.economy.workRemaining);out.work.sharedFunds=funds;
39
+ let spent=0,count=0;
40
+ for(const job of [...candidates].sort((a:State,b:State)=>a.reward-b.reward)){if(job.reward<0||spent+job.reward>funds||finite(incomeRemaining)&&spent+job.reward>incomeRemaining)continue;spent+=job.reward;count++;}
41
+ out.work.affordableTypesUpperBound=Math.min(count,shiftsLeft);
42
+ }
43
+ parts.push(`这里可接${ready.length}类工作,换地点可考虑${travel.length}类(共用工资池,名额不相加${out.work.needsCheck?';部分信息待查':''})`);
44
+ }
45
+ if(state.housing||Array.isArray(state.houses)){
46
+ const home=state.houses?.find((house:State)=>house.id===me.homeId),rent:State={};
47
+ if(Array.isArray(state.housing?.invoices)){
48
+ const unpaid=state.housing.invoices.filter((bill:State)=>bill.status==='UNPAID');rent.unpaidInvoices=unpaid.length;
49
+ if(unpaid.every((bill:State)=>finite(bill.amount)&&bill.amount>=0))rent.unpaidAmount=unpaid.reduce((sum:number,bill:State)=>sum+bill.amount,0);
50
+ }
51
+ if(home?.tenantId===me.id&&!home.ownerId){if(finite(home.rent))rent.amount=home.rent;if(finite(home.leaseDueAt)&&Math.abs(home.leaseDueAt)<=8640000000000000)rent.nextDueAt=home.leaseDueAt;if(finite(home.graceAt))rent.graceStartedAt=home.graceAt;}
52
+ if(Object.keys(rent).length){
53
+ out.rent=rent;if(rent.unpaidAmount>0)parts.push(`未付租金${rent.unpaidAmount}花币`);
54
+ if(rent.nextDueAt!==undefined){rent.due=rent.nextDueAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now);parts.push(`${rent.due?'租金账期已到':'下次租金'}${new Date(rent.nextDueAt).toISOString()}`);}
55
+ }
56
+ }
57
+ return {summary:parts.join(';'),...out};
58
+ }
59
+ export function selectState(state:State,sections?:unknown):State{
60
+ const source=normalized(state);
61
+ if(sections===undefined)return {...pick(source,legacySections),...summaries(source)}; // Keep old unparameterized clients compatible.
62
+ if(!Array.isArray(sections)||!sections.length||sections.length>stateSections.length+2||sections.some(s=>typeof s!=='string'||!['core','full',...stateSections].includes(s)))throw Error('sections须为有效状态章节数组');
63
+ if(sections.includes('full'))return source;
64
+ const keys=[...new Set(sections.flatMap(s=>s==='core'?[...coreSections]:[s]))];
65
+ const projection=pick(source,keys);
66
+ // Explicit chapters override their core summary, independent of requested order.
67
+ if(sections.includes('core'))for(const [key,value]of Object.entries(summaries(source)))if(!sections.includes(key))projection[key]=value;
68
+ return {...projection,...(sections.includes('core')?{decisionSummary:decisionSummary(source)}:{}),sectionsAvailable:stateSections.filter(key=>source[key]!==undefined),view:'selected',requestedSections:sections};
69
+ }
70
+ export function compactReceipt(result:State):State{return result?.state?.me?{...result,state:selectState(result.state,['core'])}:result;}
71
+ // Server clock ticks do not represent decisions. Crop maturity and turn transitions do.
72
+ export function eventVersion(state:State,now=Date.now()):State{
73
+ const short=summaries(state),changes:State={};
74
+ if(short.economyV7!==undefined)changes.economyV7=short.economyV7===null?null:pick(short.economyV7,['policy','earnedToday','incomeRemaining','pendingIncome','publicCustody']);
75
+ if(short.marketRegulation!==undefined){const market=short.marketRegulation;changes.marketRegulation=market===null?null:{...pick(market,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),resources:pick(market.resources??{},['location','availableKinds','totalKinds'])};}
76
+ if(short.whale!==undefined)changes.whale=short.whale===null?null:pick(short.whale,['joined','open','catchingUp','rules','stocks','me']);
77
+ return {...pick(state,['me','chat','onboarding','activeAction','claims','botSafety','quiz','parlor','orders','trade','custody','shops','mailbox','visits','projects']),...changes,plots:state.plots?.map((p:{maturesAt?:number})=>({...p,mature:typeof p.maturesAt==='number'&&p.maturesAt<=now})),clock:pick(state.clock??{},['day','season','weather'])};
78
+ }
79
+ export function stateEvent(state:State,previous:State|null,version:State){
80
+ const changedSections=Object.keys(version).filter(key=>!previous||JSON.stringify(version[key])!==JSON.stringify(previous[key]));
81
+ if(!changedSections.length)return null;
82
+ const core=selectState(state,['core']);
83
+ return {trust:'server-state-with-untrusted-user-fields',mode:previous?'changes':'snapshot',changedSections,state:previous?{...pick(core,changedSections),sectionsAvailable:core.sectionsAvailable}:core,read:'Read changedSections with bloom_state({sections:["core",...changedSections]}) when needed. Omitted payloads do not mean empty or unchanged.'};
84
+ }