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/CHANGELOG.md +11 -2
- package/ENV_PROMPT.md +2 -2
- package/README.md +135 -144
- package/bin/bloom-client.mjs +4 -5
- package/bin/join-bot.mjs +34 -0
- package/describe.json +383 -383
- package/dist/index.js +80 -18
- package/package.json +1 -1
- package/src/index.ts +27 -6
- package/src/observation.ts +84 -82
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
172
|
-
const
|
|
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(() =>
|
|
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
|
|
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
|
|
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
|
-
|
|
631
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
}
|
package/src/observation.ts
CHANGED
|
@@ -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
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if(finite(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if(sections
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if(short.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
+
}
|