@lily-cooperative/custom-farm 0.35.0 → 0.37.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.
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  | **农场自定义 → 杂草** | 增删杂草、阶段与天数、出现权重、上下图层、收获物名字/价值/贴图 |
26
26
  | **农场自定义 → 浇水特效 / 枯萎** | 浇水帧序列、水花帧序列、帧间隔、枯萎贴图 |
27
27
  | **生态自定义** | 一个游戏日多长、离线最多补几天、刷新间隔、每天是否推事件、浇水蒸发/补水/枯萎天数、天气 |
28
- | **天气** | 增删天气(无风不可删)、持续时间、权重、作物/装饰摇摆方向与幅度、压扁幅度、速度、心情变化 |
28
+ | **天气** | 增删天气(无风不可删)、持续时间、权重、作物/装饰摇摆方向与幅度、压扁幅度、速度倍率(0~20)、心情变化 |
29
29
  | **状态自定义** | 数值状态、开关状态、条件规则、消耗行为(例如饥饿时自动吃背包里的东西) |
30
30
  | **道具自定义** | 种子/农作物/消耗品/装饰的买价卖价、能不能吃、吃掉后的状态效果、图标、摆放贴图、图层、缩放 |
31
31
  | **合成表** | 1~4 个材料合成 1 个产物,桌宠可以用 `farm_craft` 合成 |
@@ -71,7 +71,9 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install.ps1
71
71
  Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
72
72
  ```
73
73
 
74
- ## 安装路径
74
+ ## 游戏配置
75
+
76
+ ### 安装路径
75
77
 
76
78
  在 Coopanion 的 **游戏配置 → 安装** 里可以直接看到和修改安装路径:
77
79
 
@@ -80,6 +82,23 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
80
82
  - 存档和自定义配置仍在 `data\home\companion\data\farm\`,不会被搬走
81
83
  - 安装目录里的 `overlay\runtime\` 是运行痕迹,可以清;`assets\sprites\cust_*.png` 是你上传的贴图
82
84
 
85
+ ### 显示器
86
+
87
+ **游戏配置 → 显示器** 可以选择图层放在哪块屏幕:
88
+
89
+ - 「自动」使用主屏
90
+ - 下拉里会列出当前所有显示器,改完约 1 秒内移动到目标屏幕
91
+ - 这个设置作用于 Electron 图层;WPF 回退仍跟随桌宠所在屏幕
92
+
93
+ ### 备份与重置
94
+
95
+ **游戏配置** 页底部有「存档与重置」:
96
+
97
+ - **备份存档**:把 `state.json` 和 `custom.json` 复制到 `data\farm\backups\backup-时间\`
98
+ - **重置一切**:先自动备份,再清空 `state.json` 和 `custom.json`,World 自动重启并生成默认目录;安装路径和已上传的 PNG 文件不会被删除
99
+
100
+ 也可以从「存档与重置」页签操作同样的按钮。
101
+
83
102
  ## 桌面图层
84
103
 
85
104
  - **Windows 10/11**:默认 Electron/HTML 图层;Electron 不可用时回退 WPF。
@@ -91,7 +110,7 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
91
110
 
92
111
  | 工具 | 用途 |
93
112
  |---|---|
94
- | `farm_status` | 看每棵作物的位置、等级、进度、水分、是否可收/枯萎,背包、掉落物和最近田里的变化 |
113
+ | `farm_status` | 看每棵作物的位置、等级、进度、水分、是否可收/枯萎,背包、掉落物、摆放的装饰物位置和最近田里的变化 |
95
114
  | `farm_stand` | 站到某个位置,`at` 必填 |
96
115
  | `farm_plant` | 在脚下种一棵,消耗 1 包种子 |
97
116
  | `farm_water` | 浇脚下一片 |
@@ -117,8 +136,8 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
117
136
  | `cropScale` / `dropScale` | 1.5 / 0.6 | 作物 / 掉落物大小 |
118
137
  | `groundHeight` / `stripHeight` | 5 / 320 | 地表高度 / 图层纵向覆盖 |
119
138
  | `realMinutesPerDay` | 60 | 1 游戏日 = 多少真实分钟 |
120
- | `maxStepDays` | 1 | 一次最多补几个游戏日 |
121
- | `tickIntervalMs` | 5000 | 刷新间隔 |
139
+ | `maxStepDays` | 1 | 离线回来最多补几个游戏日(安全上限,防止关机一夜跳几十天) |
140
+ | `tickIntervalMs` | 5000 | 刷新间隔;只影响状态/事件更新频率,不改变生长速度 |
122
141
  | `startCoins` | 120 | 新开局金币 |
123
142
  | `moodIntervalMin` | 10 | 心情按真实分钟结算的间隔 |
124
143
  | 浇水 | 0.15 / 1 / 2.2 | 每天蒸发 / 一次补水 / 干透后枯萎天数 |
@@ -151,6 +170,7 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
151
170
  - Coopanion `>= 0.2.13`,World API `6`
152
171
  - Node.js `>= 22`(Coopanion 自带)
153
172
  - Electron `^45.1.0`(桌面图层依赖)
173
+ - Windows 10/11 已实机测试;macOS / Linux 未实机测试
154
174
 
155
175
  ## 许可
156
176
 
package/describe.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "tools": [
3
3
  {
4
4
  "name": "farm_status",
5
- "description": "查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。",
5
+ "description": "查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、摆放的装饰物在哪个位置、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。",
6
6
  "parameters": {
7
7
  "type": "object",
8
8
  "properties": {}
@@ -12,8 +12,8 @@
12
12
  "snapshot"
13
13
  ],
14
14
  "summary": {
15
- "zh": "看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物和最近田里的变化(少了哪棵、为什么)。",
16
- "en": "Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, and recent field changes (which plant is gone and why)."
15
+ "zh": "看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物、摆放的装饰物位置和最近田里的变化(少了哪棵、为什么)。",
16
+ "en": "Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, placed decorations and recent field changes (which plant is gone and why)."
17
17
  }
18
18
  },
19
19
  {
package/dist/webui.js CHANGED
@@ -22,14 +22,99 @@ function renameFarmConfigTab() {
22
22
  if (typeof document !== 'undefined') {
23
23
  const start = () => {
24
24
  renameFarmConfigTab();
25
- new MutationObserver(renameFarmConfigTab).observe(document.body, { childList: true, subtree: true, characterData: true });
26
- window.addEventListener('hashchange', renameFarmConfigTab);
27
- setInterval(renameFarmConfigTab, 1000);
25
+ injectFarmConfigActions();
26
+ new MutationObserver(() => { renameFarmConfigTab(); injectFarmConfigActions(); }).observe(document.body, { childList: true, subtree: true, characterData: true });
27
+ window.addEventListener('hashchange', () => { renameFarmConfigTab(); injectFarmConfigActions(); });
28
+ setInterval(() => { renameFarmConfigTab(); injectFarmConfigActions(); }, 1000);
28
29
  };
29
30
  if (document.body) start();
30
31
  else document.addEventListener('DOMContentLoaded', start, { once: true });
31
32
  }
32
33
 
34
+ /** 调 World 的 manage 面板:备份 / 重置。 */
35
+ async function farmManageCall(method) {
36
+ const res = await fetch('/api/webui/providers/' + encodeURIComponent('world:farm') + '/panels/manage/' + method, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json' },
39
+ body: JSON.stringify({ args: [] }),
40
+ });
41
+ const data = await res.json().catch(() => ({}));
42
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
43
+ return data;
44
+ }
45
+
46
+ /** 往宿主的「游戏配置」页里塞「存档与重置」按钮。 */
47
+ function injectFarmConfigActions() {
48
+ try {
49
+ const hash = String(location.hash || '');
50
+ if (!hash.includes('world%3Afarm') && !hash.includes('world:farm')) return;
51
+ const view = document.querySelector('.configview');
52
+ if (!view || view.querySelector('[data-farm-manage]')) return;
53
+
54
+ const section = document.createElement('div');
55
+ section.className = 'tsection';
56
+ section.dataset.farmManage = '1';
57
+ section.textContent = '存档与重置';
58
+
59
+ const desc = document.createElement('div');
60
+ desc.className = 'tsecdesc';
61
+ desc.textContent = '备份会复制 state.json 和 custom.json 到 data/farm/backups;重置会先备份,再清空存档和全部自定义设置,World 随后重启。';
62
+
63
+ const row = document.createElement('div');
64
+ row.className = 'trow';
65
+ const label = document.createElement('span');
66
+ label.className = 'tlabel';
67
+ label.textContent = '操作';
68
+ const field = document.createElement('div');
69
+ field.className = 'tfield';
70
+ field.style.display = 'flex';
71
+ field.style.gap = '8px';
72
+ field.style.flexWrap = 'wrap';
73
+ const status = document.createElement('span');
74
+ status.className = 'tdesc';
75
+ status.setAttribute('role', 'status');
76
+
77
+ const button = (text, primary) => {
78
+ const b = document.createElement('button');
79
+ b.type = 'button';
80
+ b.className = 'btn sm' + (primary ? ' primary' : '');
81
+ b.textContent = text;
82
+ return b;
83
+ };
84
+ const backup = button('备份存档', true);
85
+ const reset = button('重置一切', false);
86
+
87
+ backup.addEventListener('click', async () => {
88
+ backup.disabled = true;
89
+ status.textContent = '正在备份…';
90
+ try {
91
+ const r = await farmManageCall('backup');
92
+ status.textContent = r.text || '已备份';
93
+ } catch (err) {
94
+ status.textContent = '备份失败:' + ((err && err.message) || err);
95
+ } finally { backup.disabled = false; }
96
+ });
97
+
98
+ reset.addEventListener('click', async () => {
99
+ if (!window.confirm('确定重置一切?会清空存档和全部自定义设置(会先自动备份)。')) return;
100
+ reset.disabled = true;
101
+ status.textContent = '正在重置…';
102
+ try {
103
+ const r = await farmManageCall('reset');
104
+ status.textContent = r.text || '已重置,World 正在重启…';
105
+ setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 3000);
106
+ } catch (err) {
107
+ status.textContent = '重置失败:' + ((err && err.message) || err);
108
+ reset.disabled = false;
109
+ }
110
+ });
111
+
112
+ field.append(backup, reset, status);
113
+ row.append(label, field);
114
+ view.append(section, desc, row);
115
+ } catch { /* 不是宿主的配置页就算了 */ }
116
+ }
117
+
33
118
  const TEXT = {
34
119
  zh: {
35
120
  title: '农场',
@@ -56,8 +141,8 @@ const TEXT = {
56
141
  ecologyTime: '时间与生长',
57
142
  ecologyWater: '浇水',
58
143
  timePerDay: '一个游戏日 = 真实分钟',
59
- maxStepDays: '单次最多推进(游戏日)',
60
- tickMs: '刷新间隔(ms)',
144
+ maxStepDays: '离线最多补几天(安全上限)',
145
+ tickMs: '刷新间隔(ms,不影响生长)',
61
146
  dailyEvent: '每天推「新的一天」事件',
62
147
  saveEcology: '保存生态设置',
63
148
  addGround: '添加自定义贴图',
@@ -160,8 +245,8 @@ const TEXT = {
160
245
  ecologyTime: 'Time & growth',
161
246
  ecologyWater: 'Watering',
162
247
  timePerDay: 'Real minutes per game day',
163
- maxStepDays: 'Max game days per step',
164
- tickMs: 'Refresh interval (ms)',
248
+ maxStepDays: 'Max offline days (safety cap)',
249
+ tickMs: 'Refresh interval (ms, does not affect growth)',
165
250
  dailyEvent: 'Push a "new day" event daily',
166
251
  saveEcology: 'Save ecology settings',
167
252
  addGround: 'Add custom tile',
@@ -2490,7 +2575,7 @@ function ecologyPanel(ctx) {
2490
2575
  decorAmp: Math.max(0, Math.min(40, Number(w.decorAmp ?? w.swayAmp) || 0)),
2491
2576
  squash: Math.max(0, Math.min(0.6, Number(w.squash) || 0)),
2492
2577
  mood: Math.max(-100, Math.min(100, Number(w.mood) || 0)),
2493
- speed: Math.max(0.2, Math.min(4, Number(w.speed) || 1)),
2578
+ speed: Math.max(0, Math.min(20, Number(w.speed) || 1)),
2494
2579
  }));
2495
2580
  if (!out.some((w) => w.id === 'calm')) out.unshift({ ...DEFAULT_WEATHERS[0] });
2496
2581
  if (!out.some((w) => w.id !== 'calm')) out.push(...DEFAULT_WEATHERS.slice(1).map((w) => ({ ...w })));
@@ -2519,9 +2604,10 @@ function ecologyPanel(ctx) {
2519
2604
  if (!draft) draft = cloneSettings(snapshot?.settings);
2520
2605
  return draft;
2521
2606
  };
2522
- const num = (value, onChange, step = '1', min = '0') => {
2607
+ const num = (value, onChange, step = '1', min = '0', max = '') => {
2523
2608
  const input = ui.input({ type: 'number', value: String(value ?? '') });
2524
2609
  input.step = step; input.min = min;
2610
+ if (max !== '') input.max = max;
2525
2611
  input.addEventListener('input', () => { const n = Number(input.value); if (Number.isFinite(n)) onChange(n); }, { signal: ctx.signal });
2526
2612
  return input;
2527
2613
  };
@@ -2596,7 +2682,7 @@ function ecologyPanel(ctx) {
2596
2682
  row1.append(
2597
2683
  ui.field(zh ? '持续(秒)' : 'Duration (s)', num(w.duration, (v) => { w.duration = v; }, '1', '1')),
2598
2684
  ui.field(zh ? '触发概率(权重)' : 'Probability (weight)', num(w.weight, (v) => { w.weight = v; }, '1', '0')),
2599
- ui.field(zh ? '速度(倍率)' : 'Speed (x)', num(w.speed, (v) => { w.speed = v; }, '0.1', '0.2')),
2685
+ ui.field(zh ? '速度(倍率)' : 'Speed (x)', num(w.speed, (v) => { w.speed = v; }, '0.1', '0', '20')),
2600
2686
  ui.field(zh ? '心情变化/结算' : 'Mood per settlement', num(w.mood, (v) => { w.mood = v; }, '1', '-100')),
2601
2687
  );
2602
2688
  const crop = ui.h('div', 'cf-row');
@@ -2633,13 +2719,15 @@ function ecologyPanel(ctx) {
2633
2719
  const d = getDraft();
2634
2720
  const sheet = ui.sheet({
2635
2721
  title: zh ? '生态自定义' : 'Ecology',
2636
- desc: zh ? '生长条件与天气。天气按持续时间切换,触发概率按权重计算。' : 'Growth conditions and weather. Weather changes by duration and weighted probability.',
2722
+ desc: zh
2723
+ ? '1 游戏日 = 现实分钟决定生长速度;离线最多补几天是防止关机回来一次跳太多天;刷新间隔只影响状态/事件更新,不影响生长速度。天气按持续时间切换,触发概率按权重计算。'
2724
+ : 'Real minutes per game day sets growth speed; max offline days caps how many game days a long shutdown can catch up; the refresh interval only affects state/event updates, not growth speed. Weather changes by duration and weighted probability.',
2637
2725
  });
2638
2726
  const time = ui.h('div', 'cf-row');
2639
2727
  time.append(
2640
2728
  ui.field(zh ? '1 游戏日 = 现实分钟' : 'Real minutes per game day', num(d.realMinutesPerDay, (v) => { d.realMinutesPerDay = v; }, '0.5', '0.5')),
2641
- ui.field(zh ? '单次最多补几天' : 'Max offline days', num(d.maxStepDays, (v) => { d.maxStepDays = v; }, '0.1', '0.01')),
2642
- ui.field(zh ? '刷新间隔(毫秒)' : 'Tick interval (ms)', num(d.tickIntervalMs, (v) => { d.tickIntervalMs = v; }, '100', '1000')),
2729
+ ui.field(zh ? '离线最多补几天(安全上限)' : 'Max offline days (safety cap)', num(d.maxStepDays, (v) => { d.maxStepDays = v; }, '0.1', '0.01')),
2730
+ ui.field(zh ? '刷新间隔(毫秒,不影响生长)' : 'Refresh interval (ms, does not affect growth)', num(d.tickIntervalMs, (v) => { d.tickIntervalMs = v; }, '100', '1000')),
2643
2731
  );
2644
2732
  const daily = ui.checkbox(zh ? '每天推送 farm.day' : 'Emit farm.day', {
2645
2733
  checked: d.dailyEvent,
@@ -2697,8 +2785,52 @@ function ecologyPanel(ctx) {
2697
2785
  reload();
2698
2786
  }
2699
2787
 
2788
+ function managePanel(ctx) {
2789
+ const { ui } = ctx;
2790
+ const root = ui.h('div', 'cf-root');
2791
+ ctx.root.appendChild(root);
2792
+ const sheet = ui.sheet({
2793
+ title: '存档与重置',
2794
+ desc: '备份会复制 state.json 和 custom.json 到 data/farm/backups;重置会先备份,再清空存档和全部自定义设置,World 随后重启。',
2795
+ });
2796
+ const bar = ui.rowbar();
2797
+ const status = ui.h('p', 'cf-note', '');
2798
+ const backup = ui.button('备份存档', {
2799
+ variant: 'primary',
2800
+ onClick: async () => {
2801
+ backup.disabled = true;
2802
+ status.textContent = '正在备份…';
2803
+ try {
2804
+ const r = await ctx.invoke('backup');
2805
+ status.textContent = r.text || '已备份';
2806
+ } catch (err) {
2807
+ status.textContent = '备份失败:' + ((err && err.message) || err);
2808
+ } finally { backup.disabled = false; }
2809
+ },
2810
+ });
2811
+ const reset = ui.button('重置一切', {
2812
+ onClick: async () => {
2813
+ if (!window.confirm('确定重置一切?会清空存档和全部自定义设置(会先自动备份)。')) return;
2814
+ reset.disabled = true;
2815
+ status.textContent = '正在重置…';
2816
+ try {
2817
+ const r = await ctx.invoke('reset');
2818
+ status.textContent = r.text || '已重置,World 正在重启…';
2819
+ setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 3000);
2820
+ } catch (err) {
2821
+ status.textContent = '重置失败:' + ((err && err.message) || err);
2822
+ reset.disabled = false;
2823
+ }
2824
+ },
2825
+ });
2826
+ bar.append(backup, reset);
2827
+ sheet.body.append(bar, status);
2828
+ root.appendChild(sheet.el);
2829
+ }
2830
+
2700
2831
  export default {
2701
2832
  panels: {
2833
+ manage: { mount: managePanel },
2702
2834
  bag: { mount: bagPanel },
2703
2835
  shop: { mount: shopPanel },
2704
2836
  customize: { mount: panel },
@@ -9,6 +9,7 @@ function arg(name, fallback = '') {
9
9
  }
10
10
 
11
11
  const statePath = path.resolve(arg('state-path', path.join(__dirname, '..', 'overlay', 'runtime', 'state.json')));
12
+ const screensPath = path.join(path.dirname(statePath), 'screens.json');
12
13
  const assetsDir = path.resolve(arg('assets-dir', path.join(__dirname, '..', 'assets', 'sprites')));
13
14
  const logPath = arg('log', '');
14
15
  const pidPath = arg('pid', '');
@@ -46,10 +47,34 @@ function checkLease() {
46
47
  }
47
48
 
48
49
 
50
+ function listScreens() {
51
+ const primary = screen.getPrimaryDisplay();
52
+ return screen.getAllDisplays().map((d, i) => ({
53
+ id: String(d.id),
54
+ label: d.label || ('Display ' + (i + 1)),
55
+ primary: d.id === primary.id,
56
+ width: Math.round(d.bounds.width),
57
+ height: Math.round(d.bounds.height),
58
+ }));
59
+ }
60
+
61
+ function writeScreens() {
62
+ try {
63
+ fs.mkdirSync(path.dirname(screensPath), { recursive: true });
64
+ fs.writeFileSync(screensPath, JSON.stringify(listScreens(), null, 2), 'utf8');
65
+ } catch (e) { log('screens.json failed: ' + e.message); }
66
+ }
67
+
68
+ function displayFor(st) {
69
+ const all = screen.getAllDisplays();
70
+ const id = String(st?.screen ?? '');
71
+ return all.find((d) => String(d.id) === id) || screen.getPrimaryDisplay();
72
+ }
73
+
49
74
  function bounds() {
50
75
  const st = readState() || {};
51
76
  const h = Math.min(800, Math.max(120, Number(st.stripHeight) || 320));
52
- const wa = screen.getPrimaryDisplay().workArea;
77
+ const wa = displayFor(st).workArea;
53
78
  return { x: Math.round(wa.x), y: Math.round(wa.y + wa.height - h), width: Math.round(wa.width), height: h };
54
79
  }
55
80
 
@@ -103,21 +128,22 @@ if (!app.requestSingleInstanceLock()) {
103
128
  app.on('second-instance', () => { if (win) win.showInactive(); });
104
129
  app.whenReady().then(() => {
105
130
  writePid();
131
+ writeScreens();
106
132
  createWindow();
107
- screen.on('display-metrics-changed', () => {
133
+ const fit = () => {
108
134
  if (!win) return;
109
135
  const b = bounds();
110
- win.setBounds(b);
111
- win.webContents.send('resize');
112
- });
136
+ const cur = win.getBounds();
137
+ if (cur.x !== b.x || cur.y !== b.y || cur.width !== b.width || cur.height !== b.height) {
138
+ win.setBounds(b);
139
+ win.webContents.send('resize');
140
+ }
141
+ };
142
+ for (const event of ['display-added', 'display-removed', 'display-metrics-changed']) {
143
+ screen.on(event, fit);
144
+ }
113
145
  setInterval(checkLease, 5000);
114
- setInterval(() => {
115
- if (!win) return;
116
- const st = readState() || {};
117
- const h = Math.min(800, Math.max(120, Number(st.stripHeight) || 320));
118
- const b = bounds();
119
- if (win.getBounds().height !== h) { win.setBounds(b); win.webContents.send('resize'); }
120
- }, 1000);
146
+ setInterval(fit, 1000);
121
147
  });
122
148
  }
123
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lily-cooperative/custom-farm",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "publishConfig": {
package/src/catalog.js CHANGED
@@ -387,6 +387,8 @@ export class FarmCatalog {
387
387
  recipes: [],
388
388
  settings: {},
389
389
  seededDecor: false,
390
+ seededCabin: false,
391
+ seededDecorSet: false,
390
392
  seededFreeCrops: false,
391
393
  seededFood: false,
392
394
  seededMood: false,
@@ -472,18 +474,31 @@ export class FarmCatalog {
472
474
  this.persist();
473
475
  }
474
476
 
475
- /** 旧存档切到免费素材作物集:补全新作物,删掉没有替代的小麦/卷心菜/葡萄。 */
477
+ /** 旧存档切到免费素材作物集:补全新作物,删掉没有替代的小麦/卷心菜/葡萄。只迁移一次,之后保留用户改动。 */
476
478
  ensureFreeCrops() {
477
479
  if (!this.store) return;
480
+ const removed = new Set(['wheat', 'chinese_cabbage', 'grape']);
481
+ const filterConsume = () => {
482
+ let changed = false;
483
+ for (const action of this.store.consume ?? []) {
484
+ const before = (action.items ?? []).length;
485
+ action.items = (action.items ?? []).filter((id) => !removed.has(id) && !removed.has(String(id).replace(/^seed_/, '')));
486
+ if ((action.items ?? []).length !== before) changed = true;
487
+ }
488
+ return changed;
489
+ };
490
+ if (this.store.seededFreeCrops) {
491
+ const before = this.store.crops.length;
492
+ this.store.crops = this.store.crops.filter((c) => !removed.has(c.key));
493
+ if (filterConsume() || this.store.crops.length !== before) this.persist();
494
+ return;
495
+ }
478
496
  const defs = new Map(this.builtinCrops().map((c) => [c.key, c]));
479
- const removed = new Set(["wheat", "chinese_cabbage", "grape"]);
480
497
  const order = Object.keys(CROPS);
481
498
  // 旧档没有 builtin 标记,但 wheat/chinese_cabbage/grape 一定是旧内置作物;删掉。
482
499
  const custom = this.store.crops.filter((c) => !c.builtin && !removed.has(c.key));
483
500
  this.store.crops = order.map((key) => defs.get(key)).filter(Boolean).concat(custom);
484
- for (const action of this.store.consume ?? []) {
485
- action.items = (action.items ?? []).filter((id) => !removed.has(id) && !removed.has(String(id).replace(/^seed_/, "")));
486
- }
501
+ filterConsume();
487
502
  this.store.seededFreeCrops = true;
488
503
  this.persist();
489
504
  }
@@ -519,20 +534,25 @@ export class FarmCatalog {
519
534
  !(r.inputs ?? []).some((x) => removed.has(x.item)));
520
535
  if ((this.store.items?.length ?? 0) !== beforeItems || (this.store.recipes?.length ?? 0) !== beforeRecipes) this.persist();
521
536
  }
522
- /** 稻草堆下架:删掉目录道具、背包和已摆放的旧实例。 */
523
- /** 小木屋是后加的内置装饰;旧存档也补上。 */
537
+ /** 小木屋是后加的内置装饰;只在迁移时补一次,用户删掉后不再回来。 */
524
538
  ensureCabin() {
525
- if (!this.store) return;
526
- if ((this.store.items ?? []).some((it) => it.id === 'deco_cabin')) return;
527
- const def = this.builtinDecorItems().find((it) => it.id === 'deco_cabin');
528
- if (!def) return;
529
- const item = this.normalizeItem(def, this.store.items.length, this.store.states ?? []);
530
- if (item) { this.store.items.push(item); this.persist(); }
539
+ if (!this.store || this.store.seededCabin) return;
540
+ const list = this.store.items ?? (this.store.items = []);
541
+ if (!list.some((it) => it.id === 'deco_cabin')) {
542
+ const def = this.builtinDecorItems().find((it) => it.id === 'deco_cabin');
543
+ if (def) {
544
+ const item = this.normalizeItem(def, list.length, this.store.states ?? []);
545
+ if (item) list.push(item);
546
+ }
547
+ }
548
+ this.store.seededCabin = true;
549
+ this.persist();
531
550
  }
532
551
 
533
552
  ensureDecorSet() {
534
- if (!this.store) return;
553
+ if (!this.store || this.store.seededDecorSet) return;
535
554
  this.store.items = (this.store.items ?? []).filter((it) => it.id !== 'deco_haystack');
555
+ this.store.seededDecorSet = true;
536
556
  this.persist();
537
557
  }
538
558
  /** 老存档补一次内置装饰;只补一次,之后用户删了也不会自动回来。 */
@@ -620,6 +640,8 @@ export class FarmCatalog {
620
640
  recipes,
621
641
  settings: this.normalizeSettings(raw.settings),
622
642
  seededDecor: Boolean(raw.seededDecor),
643
+ seededCabin: Boolean(raw.seededCabin),
644
+ seededDecorSet: Boolean(raw.seededDecorSet),
623
645
  seededFreeCrops: Boolean(raw.seededFreeCrops),
624
646
  seededFood: Boolean(raw.seededFood),
625
647
  seededMood: Boolean(raw.seededMood),
package/src/farm.js CHANGED
@@ -49,6 +49,8 @@ export const REACH = 0.05;
49
49
  export const DEFAULTS = {
50
50
  /** 安装目录:overlay / overlay-electron / assets 放在这里;空 = <dataDir>/custom-farm */
51
51
  installPath: '',
52
+ /** 选中的显示器 id;空 = 自动(主屏)。 */
53
+ screen: '',
52
54
  /** 最多同时种几棵(屏幕上不重叠地放得下的上限) */
53
55
  maxPlants: 12,
54
56
  /** 屏幕逻辑宽度,用来把"像素宽"换算成 x 间距 */
@@ -1074,6 +1076,7 @@ export function overlayView(state) {
1074
1076
  weatherFx: state.weatherFx ?? weatherFxOf(normalizeWeathers(DEFAULT_WEATHERS)[0]),
1075
1077
  current: state.current ?? null,
1076
1078
  currentAt: Number.isFinite(state.standingAt) ? Number(state.standingAt) : null,
1079
+ screen: state.screen ?? '',
1077
1080
  farm: { min: fb.min, max: fb.max, cells: fb.cells, cropScale: fb.cropScale, cropZoom: Number(state.cropScale) || 1 },
1078
1081
  ground: { tile: groundTile },
1079
1082
  waterFx: live.status ? {
@@ -1162,7 +1165,8 @@ export function overlayView(state) {
1162
1165
 
1163
1166
  /** 给工具看的摘要。 */
1164
1167
  export function summary(state, cfg = DEFAULTS) {
1165
- const total = Object.values(state.inventory).reduce((a, b) => a + b, 0);
1168
+ const inventory = Object.fromEntries(Object.entries(state.inventory ?? {}).filter(([id]) => Boolean(live.items?.[id])));
1169
+ const total = Object.values(inventory).reduce((a, b) => a + b, 0);
1166
1170
  const weedList = (state.weeds ?? []).map((w) => {
1167
1171
  const t = weedTypeOf(w);
1168
1172
  const lv = Number(w.level ?? 0);
@@ -1180,6 +1184,17 @@ export function summary(state, cfg = DEFAULTS) {
1180
1184
  };
1181
1185
  });
1182
1186
  const ripeWeeds = weedList.filter((w) => w.ready);
1187
+ const decorations = (state.furniture ?? [])
1188
+ .filter((f) => f.x !== null && f.x !== undefined)
1189
+ .map((f) => ({
1190
+ uid: f.uid,
1191
+ id: f.id,
1192
+ name: live.names?.[f.id] ?? f.id,
1193
+ x: round2(Number(f.x)),
1194
+ xPct: Math.round(Number(f.x) * 100),
1195
+ layer: live.items?.[f.id]?.layer ?? f.layer ?? 'bottom',
1196
+ }))
1197
+ .sort((a, b) => a.x - b.x);
1183
1198
  return {
1184
1199
  day: state.day,
1185
1200
  clock: clockText(state.minute),
@@ -1190,7 +1205,7 @@ export function summary(state, cfg = DEFAULTS) {
1190
1205
  standingAt: Number.isFinite(state.standingAt) ? Number(state.standingAt) : null,
1191
1206
  realMinutesPerDay: Number(cfg.realMinutesPerDay),
1192
1207
  bagTotal: total,
1193
- bag: { ...state.inventory },
1208
+ bag: inventory,
1194
1209
  drops: state.drops.length,
1195
1210
  planted: state.plots.length,
1196
1211
  max: cfg.maxPlants,
@@ -1199,6 +1214,7 @@ export function summary(state, cfg = DEFAULTS) {
1199
1214
  weeds: weedList.length,
1200
1215
  weedRipe: ripeWeeds.length,
1201
1216
  weedList: ripeWeeds,
1217
+ decorations,
1202
1218
  states: (Array.isArray(cfg.stateDefs) ? cfg.stateDefs : []).map((d) => {
1203
1219
  const value = state.states?.[d.id] ?? {};
1204
1220
  const flag = d.kind === 'flag';
package/src/index.js CHANGED
@@ -3,12 +3,12 @@
3
3
  */
4
4
  import { CROPS, ROTATION } from './crops.js';
5
5
  import { DEFAULTS, overlayView, summary } from './farm.js';
6
- import { FARM_TOOL_DECLS, FarmWorld } from './world.js';
6
+ import { FARM_TOOL_DECLS, FarmWorld, screenOptions } from './world.js';
7
7
 
8
8
  const TOOL_SUMMARIES = {
9
9
  farm_status: {
10
- zh: '看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物和最近田里的变化(少了哪棵、为什么)。',
11
- en: 'Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, and recent field changes (which plant is gone and why).',
10
+ zh: '看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物、摆放的装饰物位置和最近田里的变化(少了哪棵、为什么)。',
11
+ en: 'Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, placed decorations and recent field changes (which plant is gone and why).',
12
12
  },
13
13
  farm_stand: {
14
14
  zh: '站到某个位置(at 必填),播种/收割/捡东西都要先站过去。',
@@ -64,7 +64,9 @@ export const farmDefinition = {
64
64
  botDir: ctx.botDir,
65
65
  packageDir: ctx.packageDir,
66
66
  persist: (patch) => ctx.persist(patch),
67
+ restart: () => ctx.restart(),
67
68
  }),
69
+ configOptions: (kind, language) => (kind === 'farm-screens' ? screenOptions(language) : []),
68
70
  describe: {
69
71
  tools: describeTools(FARM_TOOL_DECLS),
70
72
  events: [
package/src/weather.js CHANGED
@@ -36,7 +36,7 @@ export function normalizeWeathers(input) {
36
36
  decorAmp: clamp(row.decorAmp ?? row.swayAmp, 0, 40, 3),
37
37
  squash: clamp(row.squash, 0, 0.6, 0),
38
38
  mood: clamp(row.mood ?? { calm: 2, breeze: 1, gale: -6, egg: 4 }[id], -100, 100, 0),
39
- speed: clamp(row.speed, 0.2, 4, 1),
39
+ speed: clamp(row.speed, 0, 20, 1),
40
40
  });
41
41
  }
42
42
  if (!out.some((w) => w.id === 'calm')) out.unshift({ ...DEFAULT_WEATHERS[0] });
package/src/world.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { spawn } from 'node:child_process';
19
19
  import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { execFileSync } from 'node:child_process';
21
- import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
21
+ import { mkdir, copyFile, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
22
22
  import { dirname, join, resolve, sep } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
 
@@ -73,6 +73,17 @@ import {
73
73
  } from './farm.js';
74
74
 
75
75
  const ENV_PROMPT = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
76
+ /** Electron 图层写出的显示器列表;配置页的「显示器」下拉用它。 */
77
+ let screenChoices = [];
78
+
79
+ export function screenOptions(language = 'zh') {
80
+ const zh = String(language).toLowerCase().startsWith('zh');
81
+ const out = [{ value: '', label: zh ? '自动(主屏)' : 'Auto (primary)' }];
82
+ for (const [i, d] of screenChoices.entries()) {
83
+ out.push({ value: String(d.id), label: String(d.label || (zh ? '显示器 ' : 'Display ') + (i + 1)) + (d.primary ? (zh ? '(主屏)' : ' (primary)') : '') });
84
+ }
85
+ return out;
86
+ }
76
87
  const msg = (err) => (err instanceof Error ? err.message : String(err));
77
88
 
78
89
  const pct = (x) => (x === null || x === undefined ? '?' : Math.round(Number(x) * 100));
@@ -83,7 +94,7 @@ export const FARM_TOOL_DECLS = [
83
94
  {
84
95
  name: 'farm_status',
85
96
  tags: ['read', 'snapshot'],
86
- description: '查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。',
97
+ description: '查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、摆放的装饰物在哪个位置、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。',
87
98
  parameters: { type: 'object', properties: {} },
88
99
  },
89
100
  {
@@ -297,6 +308,13 @@ function farmConfigGroups(language = 'zh', defaultHomePath = '') {
297
308
  description: t('影响之后的操作;startCoins 主要影响新开局。', 'Affects subsequent actions; startCoins mainly affects a fresh game.'),
298
309
  properties: {
299
310
  'worlds.farm.startCoins': int(t('新开局初始金币', 'Starting coins'), t('默认 120,只影响新开档。', 'Default 120; only affects a new save.'), 0, 100000),
311
+ 'worlds.farm.screen': {
312
+ type: 'string',
313
+ title: t('显示器', 'Display'),
314
+ description: t('图层放在哪块屏幕;「自动」跟随主屏。', 'Which display hosts the overlay; Auto uses the primary display.'),
315
+ 'x-options': 'farm-screens',
316
+ 'x-hot': true,
317
+ },
300
318
  'worlds.farm.overlayEnabled': bool(t('写桌面图层的 state.json', 'Write the overlay state.json'), t('默认开。关掉后桌面农场停止更新。', 'On by default. Off stops the desktop farm from updating.')),
301
319
  'worlds.farm.hideFarm': bool(t('隐藏农场', 'Hide farm'), t('勾选后停止桌面图层;农场仍在后台生长,取消勾选恢复。', 'Stop the desktop overlay; the farm keeps growing. Uncheck to show it again.')),
302
320
  },
@@ -331,6 +349,7 @@ export class FarmWorld {
331
349
  this.botDir = opts.botDir ?? process.cwd();
332
350
  this.packageDir = opts.packageDir ?? process.cwd();
333
351
  this.persistConfig = typeof opts.persist === 'function' ? opts.persist : null;
352
+ this.restartWorld = typeof opts.restart === 'function' ? opts.restart : null;
334
353
  // 是否由这个 World 管桌面图层的启停(单元测试里传 false,避免碰到真实图层)
335
354
  this.manageLayers = opts.manageLayers !== false;
336
355
  this.language = 'zh';
@@ -372,6 +391,7 @@ export class FarmWorld {
372
391
  const water = this.#catalog?.status?.water ?? null;
373
392
  return {
374
393
  ...DEFAULTS,
394
+ screen: String(this.cfg?.screen ?? ''),
375
395
  maxPlants: this.#num('maxPlants', 1, 24),
376
396
  screenWidth,
377
397
  zoom: this.#num('zoom', 1, 8),
@@ -543,6 +563,67 @@ export class FarmWorld {
543
563
  this.#ensureLayers('安装路径变更');
544
564
  }
545
565
 
566
+ /** 备份存档:把 state.json 和 custom.json 复制到 data/farm/backups/backup-时间/。 */
567
+ async #backupNow() {
568
+ const d = new Date();
569
+ const p2 = (n) => String(n).padStart(2, '0');
570
+ const stamp = d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate()) + '-' + p2(d.getHours()) + p2(d.getMinutes()) + p2(d.getSeconds());
571
+ const dir = join(this.dataDir, 'farm', 'backups', 'backup-' + stamp);
572
+ await mkdir(dir, { recursive: true });
573
+ const files = [];
574
+ for (const [name, src] of [
575
+ ['state.json', this.stateFile()],
576
+ ['custom.json', join(this.dataDir, 'farm', 'custom.json')],
577
+ ]) {
578
+ try {
579
+ if (!existsSync(src)) continue;
580
+ await copyFile(src, join(dir, name));
581
+ files.push(name);
582
+ } catch (err) {
583
+ this.#host?.log?.warn?.('自定义农场:备份文件失败', { file: src, err: msg(err) });
584
+ }
585
+ }
586
+ this.#host?.log?.info?.('自定义农场:已备份存档', { dir, files });
587
+ return {
588
+ ok: true,
589
+ path: dir,
590
+ files,
591
+ text: files.length ? '已备份到 ' + dir : '没有可备份的存档文件;已创建空备份目录 ' + dir,
592
+ };
593
+ }
594
+
595
+ /** 重置一切:先备份,再删 state.json / custom.json,最后重启 World 重新生成默认目录。 */
596
+ async #resetAll() {
597
+ const backup = await this.#backupNow().catch(() => null);
598
+ // 先断开内存里的 state,World 重启/停止时就不会把旧状态写回去。
599
+ this.#state = null;
600
+ for (const file of [this.stateFile(), join(this.dataDir, 'farm', 'custom.json')]) {
601
+ try { await unlink(file); } catch { /* 文件本来就不存在 */ }
602
+ }
603
+ this.#host?.log?.warn?.('自定义农场:已重置全部存档与设置', { backup: backup?.path ?? null });
604
+ if (this.restartWorld) this.setTimeoutImpl(() => { void this.restartWorld(); }, 300);
605
+ return {
606
+ ok: true,
607
+ backup: backup?.path ?? null,
608
+ text: '已重置存档和自定义设置' + (backup?.path ? ';备份在 ' + backup.path : '') + '。World 正在重启。',
609
+ };
610
+ }
611
+
612
+ /** 读取 Electron 图层写出的显示器列表,供游戏配置的「显示器」下拉使用。 */
613
+ #refreshScreens() {
614
+ try {
615
+ const file = join(dirname(this.overlayFile()), 'screens.json');
616
+ const list = JSON.parse(readFileSync(file, 'utf8'));
617
+ if (Array.isArray(list) && list.length) {
618
+ screenChoices = list.map((d) => ({
619
+ id: String(d.id ?? ''),
620
+ label: String(d.label ?? ''),
621
+ primary: Boolean(d.primary),
622
+ })).filter((d) => d.id);
623
+ }
624
+ } catch { /* 图层还没起来,保留上次的列表 */ }
625
+ }
626
+
546
627
  /** 自定义目录:custom.json + 安装目录下的 assets\sprites。 */
547
628
  #createCatalog() {
548
629
  const home = this.#homePath();
@@ -625,10 +706,30 @@ export class FarmWorld {
625
706
  delete this.#state.inventory?.['seed_' + key];
626
707
  }
627
708
  delete this.#state.inventory?.seed_wheat;
628
- for (const key of ['wheat', 'flour', 'pasta']) delete this.#state.inventory?.[key];
709
+ for (const key of ['wheat', 'flour']) delete this.#state.inventory?.[key];
629
710
  this.#state.furniture = (this.#state.furniture ?? []).filter((f) => f.id !== 'deco_haystack');
630
711
  delete this.#state.inventory?.deco_haystack;
631
- this.#catalog?.removeRecipesUsing(['chinese_cabbage', 'seed_chinese_cabbage', 'grape', 'seed_grape', 'wheat', 'flour', 'pasta']);
712
+ this.#catalog?.removeRecipesUsing(['chinese_cabbage', 'seed_chinese_cabbage', 'grape', 'seed_grape', 'wheat', 'flour']);
713
+ }
714
+
715
+ /** 清掉旧版本留在背包/田里的孤儿 id(比如旧家具 deco_16):当前目录里没有这个道具就删。 */
716
+ #pruneOrphanItems() {
717
+ if (!this.#state) return;
718
+ const known = new Set(Object.keys(live.items ?? {}));
719
+ if (!known.size) return;
720
+ let changed = false;
721
+ for (const id of Object.keys(this.#state.inventory ?? {})) {
722
+ if (known.has(id)) continue;
723
+ delete this.#state.inventory[id];
724
+ changed = true;
725
+ }
726
+ const beforeFurniture = (this.#state.furniture ?? []).length;
727
+ this.#state.furniture = (this.#state.furniture ?? []).filter((f) => known.has(f.id));
728
+ if (this.#state.furniture.length !== beforeFurniture) changed = true;
729
+ const beforeDrops = (this.#state.drops ?? []).length;
730
+ this.#state.drops = (this.#state.drops ?? []).filter((d) => known.has(d.item));
731
+ if (this.#state.drops.length !== beforeDrops) changed = true;
732
+ if (changed) this.#host?.log?.info?.('自定义农场:清掉旧存档里已经不存在的道具');
632
733
  }
633
734
  #ensureStarted() {
634
735
  if (!this.#started || !this.#state) throw new Error('自定义农场 World 还没启动;等它启用后再试。');
@@ -681,6 +782,9 @@ export class FarmWorld {
681
782
  lines.push('野草 ' + s.weeds + ' 丛(都还没长成;种东西时会把脚下的铲掉)。');
682
783
  }
683
784
  }
785
+ if (s.decorations.length) {
786
+ lines.push('摆放的装饰:' + s.decorations.map((d) => d.name + '(' + d.xPct + '%,' + (d.layer === 'top' ? '上层' : '下层') + ')').join('、') + '。');
787
+ }
684
788
  if (s.states.length) {
685
789
  lines.push('状态:' + s.states.map((st) => (st.kind === 'flag'
686
790
  ? st.name + (st.active ? '(生效)' : '(未生效)')
@@ -1259,7 +1363,9 @@ export class FarmWorld {
1259
1363
  this.#state.dropScale = cfg.dropScale;
1260
1364
  this.#state.groundHeight = cfg.groundHeight;
1261
1365
  this.#state.stripHeight = cfg.stripHeight;
1366
+ this.#state.screen = cfg.screen;
1262
1367
  }
1368
+ this.#refreshScreens();
1263
1369
  await writeJsonAtomic(this.overlayFile(), overlayView(this.#state));
1264
1370
  } catch (err) {
1265
1371
  this.#host?.log?.debug?.('自定义农场:图层状态没写出去', { file: this.overlayFile(), err: msg(err) });
@@ -1459,6 +1565,11 @@ export class FarmWorld {
1459
1565
  { label: '天数', value: started ? '第 ' + s.day + ' 天' : '—', tone: 'plain' },
1460
1566
  ],
1461
1567
  panels: [{
1568
+ id: 'manage',
1569
+ title: '存档与重置',
1570
+ description: '备份存档,或把存档和全部自定义设置重置为默认。',
1571
+ getMethods: ['state'],
1572
+ }, {
1462
1573
  id: 'bag',
1463
1574
  title: '背包',
1464
1575
  description: '背包、农场预览、摆放道具与手动合成;装饰在这里输入坐标放置。',
@@ -1505,6 +1616,12 @@ export class FarmWorld {
1505
1616
  async invoke(panel, method, args = []) {
1506
1617
  return this.#serial(async () => {
1507
1618
  const cfg = this.#cfgAll();
1619
+ if (panel === 'manage') {
1620
+ if (method === 'backup') return this.#backupNow();
1621
+ if (method === 'reset') return this.#resetAll();
1622
+ if (method === 'state') return { ok: true };
1623
+ throw new Error('不认识的存档操作:' + method);
1624
+ }
1508
1625
  if (panel === 'status') return this.#statusInvoke(method, args, cfg);
1509
1626
  if (panel === 'bag') return this.#bagInvoke(method, args);
1510
1627
  if (panel === 'shop') return this.#shopInvoke(method, args);
@@ -2490,6 +2607,7 @@ export class FarmWorld {
2490
2607
  }
2491
2608
  this.#state = await this.#loadOrInit();
2492
2609
  this.#pruneRemovedCrops();
2610
+ this.#pruneOrphanItems();
2493
2611
  this.#state.current = null;
2494
2612
  this.#state.standingAt = null;
2495
2613
  // 老存档的 drySince 是按"游戏分钟"存的,换成按"累计游戏日"之后要清一次,免得永远不枯萎