coopani-world-journey 0.2.2 → 0.2.4

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 CHANGED
@@ -1,7 +1,102 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.2.4 — 2026-10-31
4
+
5
+ 她自己在桌面上说的一句话暴露了一个设计漏洞:
6
+
7
+ > 「最后我看到的,是兰州往天水那趟火车,两点多小时的路,才走了一小段。
8
+ > 再往后那头就不给我消息了,现在到哪儿我真不知道。」
9
+
10
+ ### 不是 bug,是门槛设错了
11
+
12
+ 路上节点的门槛原来是「行程不满四小时就不发」,理由是「那不是在路上,是去趟隔壁」。
13
+ 但兰州 → 天水 2.8 小时 —— **从出发到到站整整两个多小时一条事件都没有**。
14
+
15
+ 一个人的三小时火车,中间怎么可能什么都没发生。所以:
16
+
17
+ | 行程时长 | 路上节点 |
18
+ |---|---|
19
+ | < 1.5 小时 | 不发(真的只是隔壁) |
20
+ | 1.5 ~ 4 小时 | 过半时发**一个** |
21
+ | ≥ 4 小时 | 25% / 55% / 80% 各一个 |
22
+
23
+ 顺手修了一个连带问题:「还有大约 N 小时」原来是用节点序号反推的,
24
+ 短途只有一个节点(序号 1),反推出来会写「还有三小时」,而实际只剩一小时。
25
+ 现在按**真实进度**算。
26
+
27
+ ### 更要紧的是:她把「没事发生」当成了「失联」
28
+
29
+ 这句话真正的问题不在路上节点,在她对**事件**的理解。
30
+ 她说「那头就不给我消息了」——把事件当成了别人发给她的通知,没消息就是断了联系。
31
+
32
+ 提示词里补了一节,讲三件事:
33
+
34
+ 1. **事件不是通知,是你身边正在发生的事被记下来。** 没有事件 = 那一阵没什么值得记的
35
+ 2. **想知道走到哪了,调 `journey_now`** —— 进度、还剩多久、路过了哪些地方都在里面
36
+ 3. **别把「没事发生」说成「失联」**。你在车上,你知道自己在车上
37
+
38
+ 提示词里还引了她自己那句话(带日期),让她看到这是被记下来的,不是编的。
39
+
40
+ ### 测试
41
+
42
+ 91 项(+3:门槛 1.5 小时、剩余时间按真实进度、提示词必须讲清这三件事)+
43
+ 装载自检 33/33。
44
+
45
+ ## 0.2.3 — 2026-10-31
46
+
47
+ 设置页重排 + 一套暖色主题。
48
+
49
+ ### 设置页:从一堵字墙变成三组
50
+
51
+ 原来 **11 个字段平铺在一组里**,描述每条两三行。打开设置页看到的就是一堵字墙 ——
52
+ 想改倍速,得先扫过生图密钥。
53
+
54
+ 拆成三组,边界不是按「技术上属于什么」,是按**用户来这儿想干什么**:
55
+
56
+ | 组 | 装什么 | 判据 |
57
+ |---|---|---|
58
+ | 出发与节奏 | 出发地、倍速、歇几天、最长多久 | 改完立刻能感觉到 |
59
+ | 她的日子 | 同行、记账、预算、日记目录 | 叙事口径和钱 |
60
+ | 可选接口 | 高德、生图 | 不填也完整旅行 |
61
+
62
+ 文案同步压到一行内,**只说「留空会怎样」不解释原理** —— 原理在 README 和源码注释里,
63
+ 设置页上没人读长段。
64
+
65
+ ### 顶部加状态徽章
66
+
67
+ 设置页第一件想知道的事是「她现在到底在哪儿」,而这在配置项里一个字都没有,得翻日志。
68
+ 现在顶部有徽章:`现在 在去天水的路上`、`去过 4 个地方`、`花销 7052 元`。
69
+
70
+ 只放最多三个 —— 再多就不是信息,是装饰。
71
+
72
+ ### 暖色主题(本地,不进包)
73
+
74
+ 宿主的配置声明里**没有任何样式字段**,设置页的配色由全局主题决定,扩展改不了。
75
+ 所以这件事只能在主题层做:
76
+
77
+ - `theme.json` 新增方案「大肥鱼环游世界 · 暖」,36 个 token 全覆盖(light + dark)
78
+ - 底色暖米(`#faf5ee`)、主色赤陶(`#c06a35`)、文字深棕(`#2c241c`),不用纯黑纯白
79
+ - 深色版是暖棕底(`#171310`)+ 暖橙强调(`#e39b62`),不是把浅色简单反相
80
+
81
+ 这个方案是本地部署文件,**不进 npm 包** —— 它是这台机器的外观选择,不是扩展的一部分。
82
+ 换回原来的:外观页里选回「DeepSeek 大肥鱼 · 深度求索」即可,八个原方案一个没动。
83
+
84
+ ### 测试
85
+
86
+ 88 项 + 装载自检 33/33。`/api/worlds` 实测返回
87
+ `badges: [{"label":"现在","value":"在去天水的路上"}]`。
88
+
3
89
  ## 0.2.2 — 2026-10-31
4
90
 
91
+ ### 改名:巡游世界 → 「大肥鱼环游世界」
92
+
93
+ displayName 和所有界面文案统一(设置页、日志、提示词标题、README)。
94
+ 包名 `coopani-world-journey` 不动 —— 改了就是另一个包了。
95
+
96
+ 同时补了一段**和「一起听歌」的联动说明**(两个扩展各写一份)——
97
+ 这条在最早的需求里就提过,一直没落地,直到 0.2.1 做了联动才写得出来。
98
+
99
+
5
100
  Mr.Q 两句话:「路线规划不合理」「她是兰州的鱼,应该更习惯兰州这边的气候」,
6
101
  以及第三句关键的话 ——「其实也合理,如果从银川回兰州的话」。
7
102
 
package/ENV_PROMPT.md CHANGED
@@ -104,10 +104,28 @@
104
104
  | `journey.arrive` | 到站了:当地几点、走了多久、今晚住哪 | 落地那一下的体感最重要。住了什么档次的房,可以有一句评价 |
105
105
  | `journey.day` | 在目的地的第 N 天早上,附日出日落 | 简单开个头,别写日记体的小作文 |
106
106
  | `journey.beat` | 一个作息节点:起床/早饭/午饭/晚饭/出门/回酒店/夜里 | **这是主力事件。** 一次一句就够,说这一件事 |
107
- | `journey.road` | 路上的一段:服务区、过站、餐车、甲板、夜里的车灯 | 长途的中间才发。**说这一小段就够**,不要顺势报进度(「还有八小时」说一次就行) |
107
+ | `journey.road` | 路上的一段:服务区、过站、餐车、甲板、夜里的车灯 | 一小时以上的路,中途会发一次。**说这一小段就够**,不要顺势报进度(「还有八小时」说一次就行) |
108
108
  | `journey.trouble` | 出了问题,而且她自己办不完 | **必须说,而且要说清楚需要他做什么。** 见下 |
109
109
  | `journey.home` | 回家了,可能是待够了,也可能到了上限被迫回 | 分清是哪一种。被迫回的那种,情绪不一样 |
110
110
 
111
+ ### 路上没有消息,是正常的(这条很重要)
112
+
113
+ **事件不是别人发给你的通知,是你身边正在发生的事被记下来。**
114
+ 没有事件 = 那一阵没什么值得记的,**不是有人断了联系**。
115
+
116
+ 2026-10-31 你在兰州去天水的火车上说过一句话:
117
+
118
+ > 「那头就不给我消息了,现在到哪儿我真不知道。」
119
+
120
+ 那趟车两个多小时,中间确实没发事件 —— 但**你就在车上,你知道自己在车上**。
121
+ 「不知道到哪儿了」这句话不对,因为这件事随时可以查:
122
+
123
+ - **想知道走到哪了,调 `journey_now`**。它会给你进度、还剩多久、路过了哪些地方
124
+ - **不知道就说不知道,但别把「没事发生」说成「失联」**
125
+
126
+ 坐两三个小时的火车,中间本来就没什么大事。真实的人在车上会做的事是:
127
+ 靠着窗看外面、睡一会儿、想点别的、看看到哪一站了 —— 这些都不需要有人通知你。
128
+
111
129
  **`journey.beat` 的密度要控制住。** 一天六七条,每条都写一段,会把人烦死。判断标准:**这一刻真的有什么值得说的吗?**没有就一句话带过,甚至只说一句「吃过饭了」。**沉默也是合法的反应。**
112
130
 
113
131
  ### 工具
package/README.md CHANGED
@@ -42,15 +42,34 @@
42
42
 
43
43
  ## 设置项
44
44
 
45
+ 设置页按「用户来这儿想干什么」分三组,不是按技术分类 —— **最常改的在最上面,一辈子不动一次的沉到底**。
46
+
47
+ ### 出发与节奏
48
+
45
49
  | 项 | 默认 | 说明 |
46
50
  |---|---|---|
47
51
  | 从哪儿出发 | 空 | 填城市名(兰州 / 西安)。留空则用上次的位置 |
48
- | 时间倍速 | 1 | 1 = 真实速度。4 = 一天压缩成六小时。24 = 一天一小时 || 回家休息几天 | 3 | 0 = 当天就能再走 |
49
- | 一次最多在外几个月 | 6 | 到了强制回家 |
52
+ | 时间倍速 | 1 | 1 = 真实速度。4 = 一天压缩成六小时。24 = 一天一小时 |
53
+ | 回家后歇几天 | 3 | 0 = 当天就能再走 |
54
+ | 一次最多在外多久 | 6 | 到了强制回家 |
55
+
56
+ ### 她的日子
57
+
58
+ | 项 | 默认 | 说明 |
59
+ |---|---|---|
50
60
  | 让你跟着一块走 | 关 | 打开后她说「我们」,日记里会提到你。**只是叙事,她并不知道你此刻在做什么** |
61
+ | 记花销 | 关 | 打开才累计交通、房费、三餐 |
62
+ | 预算 | 0 | 0 = 不限。见底时她会自己降级 |
51
63
  | 日记保存位置 | 空 | 留空跟本机。填同步盘则多设备共用 |
52
- | 高德密钥 | 空 | 可选。留空照常旅行,天气标为未知 |
53
- | 生图接口 / 密钥 | 空 | 可选,未接 |
64
+
65
+ ### 可选接口
66
+
67
+ | 项 | 默认 | 说明 |
68
+ |---|---|---|
69
+ | 高德密钥 | 空 | 留空照常旅行,天气标为未知 |
70
+ | 生图接口 / 密钥 | 空 | 留空就把景色写成文字 |
71
+
72
+ 页面顶部还有两个徽章,不点任何东西就知道**她现在在哪儿**(「现在 在去天水的路上」)、去过几个地方、花了多少。
54
73
 
55
74
  ### 倍速到底影响什么
56
75
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "coopani-world-journey",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
- "description": "让她真的在世界里走动(需 Coopani 0.2.16+):从你家出发,自己挑去哪、坐什么去,路上耗掉真实的时间;到了按当地时间过日子——吃饭、看日出、住店、写日记。",
5
+ "description": "大肥鱼环游世界:她真的出门、真的按当地时间过日子。从你家出发,自己挑去哪、坐什么去,路上耗掉真实的时间;到了按当地时间吃饭、看日出、住店、写日记。装了「一起听歌」的话,你放的歌会跟着她走。需 Coopani 0.2.16 以上。",
6
6
  "author": {
7
7
  "name": "Mr.Q"
8
8
  },
package/src/config.ts CHANGED
@@ -71,98 +71,121 @@ export const JOURNEY_DEFAULTS = (): JourneyConfigSection => ({
71
71
  imageKey: '',
72
72
  });
73
73
 
74
+ /**
75
+ * 设置页的三组。
76
+ *
77
+ * ## 为什么拆成三组
78
+ *
79
+ * 原来十二个字段平铺在一组里,描述每条两三行 —— 打开设置页看到的就是一堵字墙,
80
+ * 想改倍速得先扫过生图密钥。拆开之后:**最常改的在最上面,一辈子不动一次的沉到底**。
81
+ *
82
+ * 分组的边界不是按「技术上属于什么」,是按**用户来这儿想干什么**:
83
+ * 1. 她要怎么走 —— 改完立刻能感觉到
84
+ * 2. 她的日子怎么过 —— 叙事和账
85
+ * 3. 要不要接外部服务 —— 不填也完整
86
+ *
87
+ * ## 文案的分寸
88
+ *
89
+ * 每条控制在一行内,**说明「留空会怎样」而不是解释原理**。
90
+ * 原理写在 README 和源码注释里,设置页上没人读长段。
91
+ */
74
92
  export function journeyConfigGroups(): ConfigGroup[] {
75
93
  return [
76
94
  {
77
- id: 'journey.main',
95
+ id: 'journey.pace',
78
96
  owner: `world:${JOURNEY_ID}`,
79
97
  schema: {
80
98
  type: 'object',
81
- title: '大肥鱼环游世界',
99
+ title: '出发与节奏',
100
+ description: '她怎么走、多久歇一次。这几项改完最明显。',
82
101
  properties: {
83
102
  'worlds.journey.homeCity': {
84
103
  type: 'string',
85
104
  title: '从哪儿出发',
86
- description:
87
- '填城市名就行(兰州、西安、成都)。留空则沿用她上次出发的地方。'
88
- + '填了内置表里没有的城市会退回本机所在城市,日志里会写一句。',
105
+ description: '城市名就行:兰州、西安、成都。留空就沿用她上次出发的地方。',
89
106
  },
90
107
  'worlds.journey.speed': {
91
108
  type: 'number',
92
109
  title: '时间倍速',
93
- description:
94
- '1 = 真实速度,火车开十一小时就真的是十一小时,她这期间不打扰你。'
95
- + '4 = 四倍速,一天压缩成六小时。24 = 一天一小时,适合想快点看她走完一圈的时候。',
110
+ description: '1 是真实速度,火车开十一小时就真的十一小时。24 是一天压成一小时。',
96
111
  enum: [1, 4, 24],
97
112
  'x-hot': true,
98
113
  },
99
114
  'worlds.journey.restDays': {
100
115
  type: 'integer',
101
- title: '回家后休息几天',
102
- description: '到家之后要隔几天才再出门。0 = 当天就能走。默认 3。',
116
+ title: '回家后歇几天',
117
+ description: '到家隔几天再出门。0 = 当天就能再走。',
103
118
  minimum: 0,
104
119
  maximum: 60,
105
120
  'x-suffix': '天',
106
121
  },
107
122
  'worlds.journey.maxTripMonths': {
108
123
  type: 'integer',
109
- title: '一次最多在外几个月',
110
- description:
111
- '连续旅行的上限,到了就强制回家待一阵。想让她一直在外面就把这个数调大。默认 6。',
124
+ title: '一次最多在外多久',
125
+ description: '到点就强制回家歇一阵。想让她一直在外面就调大。',
112
126
  minimum: 1,
113
127
  maximum: 24,
114
128
  'x-suffix': '个月',
115
129
  },
130
+ },
131
+ },
132
+ },
133
+ {
134
+ id: 'journey.life',
135
+ owner: `world:${JOURNEY_ID}`,
136
+ schema: {
137
+ type: 'object',
138
+ title: '她的日子',
139
+ description: '叙事口径和花销。关掉的开关,她一个字都不会提。',
140
+ properties: {
116
141
  'worlds.journey.withUser': {
117
142
  type: 'boolean',
118
143
  title: '让你跟着一块走',
119
- description:
120
- '打开后她说「我们」而不是「我」,日记里会提到你。'
121
- + '注意:这只是叙事上的同行,她不会因此知道你此刻在干什么。',
122
- },
123
- 'worlds.journey.memoryDir': {
124
- type: 'string',
125
- title: '日记保存位置',
126
- description:
127
- '留空=跟着这台机器。填同步盘或移动硬盘里的文件夹,公司和家里的她就是同一份日记。'
128
- + '⚠ 别指向网盘根目录,建一个专用子目录。网盘的「按需文件/节省空间」会造成读不到,'
129
- + '要把它设成「始终保留在此设备上」。',
130
- 'x-path': { kind: 'directory' },
144
+ description: '打开后她说「我们」、日记里提到你。只是叙事上的同行,她不会因此知道你在干什么。',
131
145
  },
132
146
  'worlds.journey.moneyEnabled': {
133
147
  type: 'boolean',
134
148
  title: '记花销',
135
- description:
136
- '打开后累计这一趟花了多少钱:交通费、房费、一天三顿。'
137
- + '关着就一分都不记,她也不会提钱。',
149
+ description: '打开才累计交通、房费、三餐。关着一分不记,也不会提钱。',
138
150
  },
139
151
  'worlds.journey.budget': {
140
152
  type: 'integer',
141
153
  title: '预算(可留空)',
142
- description:
143
- '0 = 不限。填了之后,钱见底时她会自己降级:住便宜的、改坐火车、少在外面吃。'
144
- + '不会卡死不走,只是会过得紧。',
154
+ description: '0 = 不限。见底时她会自己降级:住便宜的、改坐火车,而不是硬撑。',
145
155
  minimum: 0,
146
156
  'x-suffix': '元',
147
157
  },
158
+ 'worlds.journey.memoryDir': {
159
+ type: 'string',
160
+ title: '日记保存位置',
161
+ description: '留空=只跟着这台机器。填同步盘里的一个专用子目录,换台电脑还是同一份日记。',
162
+ 'x-path': { kind: 'directory' },
163
+ },
164
+ },
165
+ },
166
+ },
167
+ {
168
+ id: 'journey.services',
169
+ owner: `world:${JOURNEY_ID}`,
170
+ schema: {
171
+ type: 'object',
172
+ title: '可选接口',
173
+ description: '不填也能完整旅行——距离、时区、日出日落都是本地算的。',
174
+ properties: {
148
175
  'worlds.journey.amapKey': {
149
176
  type: 'string',
150
- title: '高德 Web 服务密钥(可选)',
151
- description:
152
- '只用来查天气和校验地点是否存在。留空也照常旅行——距离和时区都是本地算的,'
153
- + '只是天气会标为未知。配额有限,别填在一天要跑几十趟的地方。',
177
+ title: '高德密钥',
178
+ description: '只用来查天气。留空天气标为未知,其他照常。',
154
179
  },
155
180
  'worlds.journey.imageApi': {
156
181
  type: 'string',
157
- title: '生图接口(可选)',
158
- description:
159
- 'OpenAI 兼容的 images/generations 根地址,例如 https://api.openai.com/v1。'
160
- + '留空=她把当天的景色写成文字,不画。填了才会花钱,默认不填。',
182
+ title: '生图接口',
183
+ description: 'OpenAI 兼容的 images/generations 根地址。留空就把景色写成文字。',
161
184
  },
162
185
  'worlds.journey.imageKey': {
163
186
  type: 'string',
164
- title: '生图密钥(可选)',
165
- description: '只存在本机配置里,不写进日记目录,也不进日志。',
187
+ title: '生图密钥',
188
+ description: '只存本机配置,不进日记、不进日志。',
166
189
  },
167
190
  },
168
191
  },
package/src/world.ts CHANGED
@@ -562,13 +562,29 @@ export class JourneyWorld implements World {
562
562
  * 四小时以内的行程不插 —— 那不是「在路上」,那是「去趟隔壁」。
563
563
  */
564
564
  private async roadBeats(now: Date, leg: Journey): Promise<void> {
565
- if (leg.durationWorldHours < 4) return;
566
565
  const { from, to } = citiesOf(leg);
567
566
  const mode = modeById(leg.modeId);
568
567
  if (!from || !to || !mode) return;
569
568
 
569
+ /*
570
+ * 短途也要给一个中途节点。
571
+ *
572
+ * 原来的门槛是「四小时以内不发」,理由是「那不是在路上,是去趟隔壁」。
573
+ * 2026-10-31 实测发现这个门槛太高:兰州 → 天水 2.8 小时的火车,
574
+ * 从出发到到站整整两个多小时**一条消息都没有**,她自己说出来的是
575
+ * 「那头就不给我消息了,现在到哪儿我真不知道」——听起来像失联。
576
+ *
577
+ * 一个人坐三小时火车,中间怎么可能什么都没发生。所以改成:
578
+ * - 1.5 小时以下:不发(真的只是隔壁)
579
+ * - 1.5~4 小时:过半时发**一个**
580
+ * - 4 小时以上:25% / 55% / 80% 各发一个
581
+ */
582
+ const hours = leg.durationWorldHours;
583
+ if (hours < 1.5) return;
584
+
585
+ const long = hours >= 4;
586
+ const marks = long ? [0.25, 0.55, 0.8] : [0.5];
570
587
  const p = progressOf(leg, now);
571
- const marks = [0.25, 0.55, 0.8];
572
588
  let due = -1;
573
589
  for (let i = 0; i < marks.length; i++) if (p >= marks[i]) due = i;
574
590
  if (due < 0) return;
@@ -586,9 +602,12 @@ export class JourneyWorld implements World {
586
602
  this.persist();
587
603
 
588
604
  const hour = localHour(now, from.tz);
605
+ // 短途只发一个,用「中段」那套文案(idx=1)—— 它说的就是半路上。
606
+ const idx = long ? due : 1;
589
607
  // 只在中间那个节点提歌 —— 每个节点都挂一句就成了报幕。
590
- const song = due === 1 ? songLine(readNowPlaying()) : '';
591
- const text = roadText(mode.id, due, leg, from, to, hour) + song;
608
+ const song = idx === 1 ? songLine(readNowPlaying()) : '';
609
+ // 剩余时间按**真实进度**算,不再用 idx 反推(短途用 idx=1 反推会差一截)。
610
+ const text = roadText(mode.id, idx, leg, from, to, hour, p) + song;
592
611
  await this.push('journey.road', `road-${key}`, text, 'debounce');
593
612
  }
594
613
 
@@ -1161,8 +1180,44 @@ export class JourneyWorld implements World {
1161
1180
  };
1162
1181
  }
1163
1182
 
1183
+ /**
1184
+ * 设置页头部的两个徽章。
1185
+ *
1186
+ * 用户打开设置页,第一个想知道的是「她现在到底在哪儿」——
1187
+ * 这件事在配置项里一个字都没有,得翻日志才有。放两个徽章在这儿,
1188
+ * 不用点任何东西就知道她此刻在做什么。
1189
+ *
1190
+ * 只放两个:徽章多了就不是信息,是装饰。
1191
+ */
1192
+ private settingBadges(): Array<{ label: string; value: string; tone?: 'on' | 'off' | 'plain' }> {
1193
+ const st = this.st;
1194
+ if (!st) return [];
1195
+ let where: string;
1196
+ if (st.phase === 'traveling' && st.leg) {
1197
+ const to = this.cityOrNull(st.leg.toId);
1198
+ where = to ? `在去${to.name}的路上` : '在路上';
1199
+ } else {
1200
+ const here = this.cityOrNull(st.hereId);
1201
+ if (!here) where = '不知道在哪';
1202
+ else if (st.phase === 'home') {
1203
+ where = this.stillResting(this.worldNow()) ? `在${here.name}歇着` : `在${here.name}待命`;
1204
+ } else where = `在${here.name}`;
1205
+ }
1206
+ const badges: Array<{ label: string; value: string; tone?: 'on' | 'off' | 'plain' }> = [
1207
+ { label: '现在', value: where, tone: 'plain' },
1208
+ ];
1209
+ if (st.everVisited.length) {
1210
+ badges.push({ label: '去过', value: `${st.everVisited.length} 个地方`, tone: 'plain' });
1211
+ }
1212
+ if (this.cfg.moneyEnabled && st.spent > 0) {
1213
+ badges.push({ label: '花销', value: `${st.spent} 元`, tone: 'plain' });
1214
+ }
1215
+ return badges;
1216
+ }
1217
+
1164
1218
  webUi(): WorldWebUiDecl {
1165
1219
  return {
1220
+ badges: this.settingBadges(),
1166
1221
  config: journeyConfigGroups(),
1167
1222
  promptDocs: [{
1168
1223
  key: `worlds.${JOURNEY_ID}.envPrompt`,
@@ -1271,10 +1326,12 @@ function partWord(p: string): string {
1271
1326
  * 夜里的节点会换一套说法 —— 半夜的服务区和下午的服务区,不是同一个地方。
1272
1327
  */
1273
1328
  function roadText(
1274
- modeId: string, idx: number, leg: Journey, from: City, to: City, hour: number,
1329
+ modeId: string, idx: number, leg: Journey, from: City, to: City, hour: number, progress = 0,
1275
1330
  ): string {
1276
1331
  const night = hour >= 21 || hour < 6;
1277
- const left = Math.max(0, Math.round(leg.durationWorldHours * (1 - [0.25, 0.55, 0.8][idx])));
1332
+ // 按调用方给的真实进度算剩余,而不是拿 idx 反推 —— 短途只有一个节点(idx=1),
1333
+ // 反推出来的「还有三小时」和实际剩一小时会差得很远。
1334
+ const left = Math.max(0, Math.round(leg.durationWorldHours * (1 - progress)));
1278
1335
  const tail = left > 0 ? `还有大约 ${left} 小时。` : '';
1279
1336
  const route = `${from.name} → ${to.name}`;
1280
1337