dsh-piggy 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +1348 -0
  2. package/LICENSE +32 -0
  3. package/README.md +322 -0
  4. package/THIRD-PARTY.md +31 -0
  5. package/assets/elder.svg +35 -0
  6. package/assets/pig-king-bathe.svg +38 -0
  7. package/assets/pig-king-eat.svg +45 -0
  8. package/assets/pig-king-pet.svg +45 -0
  9. package/assets/pig-king-play.svg +45 -0
  10. package/assets/pig-king-relaxed.svg +56 -0
  11. package/assets/pig-king-study.svg +45 -0
  12. package/assets/pig-king-trip.svg +45 -0
  13. package/assets/pig-king-work.svg +45 -0
  14. package/assets/pig-king.svg +56 -0
  15. package/assets/piglet.svg +30 -0
  16. package/client.js +3438 -0
  17. package/commands.js +252 -0
  18. package/cordis.patch.yml +15 -0
  19. package/core.js +11 -0
  20. package/data.js +10 -0
  21. package/index.js +68 -0
  22. package/package.json +72 -0
  23. package/packages/pet-core/package.json +21 -0
  24. package/packages/pet-core/src/core/activity.js +80 -0
  25. package/packages/pet-core/src/core/care.js +160 -0
  26. package/packages/pet-core/src/core/clock.js +122 -0
  27. package/packages/pet-core/src/core/constants.js +109 -0
  28. package/packages/pet-core/src/core/daily.js +204 -0
  29. package/packages/pet-core/src/core/diary.js +108 -0
  30. package/packages/pet-core/src/core/drops.js +82 -0
  31. package/packages/pet-core/src/core/effects.js +84 -0
  32. package/packages/pet-core/src/core/egg.js +120 -0
  33. package/packages/pet-core/src/core/evolution.js +90 -0
  34. package/packages/pet-core/src/core/growth.js +110 -0
  35. package/packages/pet-core/src/core/illness.js +235 -0
  36. package/packages/pet-core/src/core/interests.js +55 -0
  37. package/packages/pet-core/src/core/inventory.js +177 -0
  38. package/packages/pet-core/src/core/lines.js +157 -0
  39. package/packages/pet-core/src/core/migrate.js +226 -0
  40. package/packages/pet-core/src/core/profile.js +102 -0
  41. package/packages/pet-core/src/core/random.js +79 -0
  42. package/packages/pet-core/src/core/school.js +81 -0
  43. package/packages/pet-core/src/core/settlement.js +308 -0
  44. package/packages/pet-core/src/core/state.js +210 -0
  45. package/packages/pet-core/src/core/travel.js +58 -0
  46. package/packages/pet-core/src/core/upgrades.js +193 -0
  47. package/packages/pet-core/src/core/views.js +55 -0
  48. package/packages/pet-core/src/core/work.js +50 -0
  49. package/packages/pet-core/src/core.js +95 -0
  50. package/packages/pet-core/src/data/daily.js +121 -0
  51. package/packages/pet-core/src/data/drops.js +34 -0
  52. package/packages/pet-core/src/data/evolution.js +41 -0
  53. package/packages/pet-core/src/data/growth.js +72 -0
  54. package/packages/pet-core/src/data/illness.js +188 -0
  55. package/packages/pet-core/src/data/interests.js +39 -0
  56. package/packages/pet-core/src/data/jobs.js +150 -0
  57. package/packages/pet-core/src/data/life.js +88 -0
  58. package/packages/pet-core/src/data/lines.js +198 -0
  59. package/packages/pet-core/src/data/minutes.js +23 -0
  60. package/packages/pet-core/src/data/profile.js +60 -0
  61. package/packages/pet-core/src/data/school.js +99 -0
  62. package/packages/pet-core/src/data/shop.js +142 -0
  63. package/packages/pet-core/src/data/traits.js +42 -0
  64. package/packages/pet-core/src/data/travel.js +87 -0
  65. package/packages/pet-core/src/data.js +52 -0
  66. package/packages/pet-core/src/index.js +12 -0
  67. package/render.js +357 -0
  68. package/routes.js +201 -0
  69. package/scripts/build-client.mjs +100 -0
  70. package/scripts/typecheck.mjs +122 -0
  71. package/snapshot.js +368 -0
  72. package/src/client/art.js +29 -0
  73. package/src/client/constants.js +88 -0
  74. package/src/client/css-base.js +225 -0
  75. package/src/client/css-card.js +63 -0
  76. package/src/client/css-tabs.js +250 -0
  77. package/src/client/css-tiles.js +89 -0
  78. package/src/client/dom.js +33 -0
  79. package/src/client/effects.js +159 -0
  80. package/src/client/format.js +27 -0
  81. package/src/client/index.js +393 -0
  82. package/src/client/io.js +100 -0
  83. package/src/client/layout.js +103 -0
  84. package/src/client/normalize.js +352 -0
  85. package/src/client/panel.js +319 -0
  86. package/src/client/scene.js +113 -0
  87. package/src/client/storage.js +19 -0
  88. package/src/client/styles.js +13 -0
  89. package/src/client/tabs/bag.js +203 -0
  90. package/src/client/tabs/card.js +110 -0
  91. package/src/client/tabs/crown.js +59 -0
  92. package/src/client/tabs/dev.js +101 -0
  93. package/src/client/tabs/home.js +72 -0
  94. package/src/client/tabs/shop.js +78 -0
  95. package/src/client/tabs/status.js +223 -0
  96. package/src/client/tabs/study.js +128 -0
  97. package/src/client/tabs/travel.js +89 -0
  98. package/src/client/tabs/update.js +164 -0
  99. package/src/client/tabs/work.js +106 -0
  100. package/src/client/values.js +11 -0
  101. package/src/client/widgets.js +126 -0
  102. package/store/api.js +260 -0
  103. package/store/state-file.js +129 -0
  104. package/store.js +96 -0
package/render.js ADDED
@@ -0,0 +1,357 @@
1
+ // @ts-check
2
+ /**
3
+ * dsh-piggy · render — every word the pig says.
4
+ *
5
+ * Pure string building: the command handlers, the HTTP actions, the tests and
6
+ * the client bubble all read from here, so the pig's voice lives in exactly one
7
+ * file.
8
+ *
9
+ * @module dsh-piggy/render
10
+ */
11
+
12
+ import {
13
+ ACTIONS,
14
+ ACTION_ORDER,
15
+ JOBS,
16
+ MAX,
17
+ REVIVE_ITEM,
18
+ SCHOOL_STAGES,
19
+ SHOP,
20
+ SUBJECTS,
21
+ TRAITS,
22
+ TRAIT_ORDER,
23
+ TRIPS,
24
+ actionCooldownSeconds,
25
+ activitySecondsLeft,
26
+ bar,
27
+ courseView,
28
+ formatWeight,
29
+ healthPercent,
30
+ levelProgress,
31
+ formStageView,
32
+ mood,
33
+ outingGrowth,
34
+ traitView,
35
+ } from './core.js'
36
+ import { STUDY_GROWTH_PER_LESSON, illnessAt } from './data.js'
37
+
38
+ const RULE = '━━━━━━━━━━━━━━━━━━━━━━━━━━'
39
+
40
+ /** Which portrait face matches the mood. */
41
+ function face(currentMood) {
42
+ switch (currentMood.key) {
43
+ case 'sleepy': return { l: '˘', r: '˘', m: 'ω' }
44
+ case 'hungry': return { l: '◕', r: '◕', m: 'o' }
45
+ case 'dirty': return { l: 'ò', r: 'ó', m: '益' }
46
+ case 'sick': return { l: '×', r: '×', m: '︿' }
47
+ case 'dead': return { l: '×', r: '×', m: '︵' }
48
+ case 'working': return { l: '•', r: '•', m: 'ω' }
49
+ case 'happy': return { l: '^', r: '^', m: 'ω' }
50
+ case 'lonely': return { l: '◕', r: '◕', m: '︵' }
51
+ default: return { l: '◕', r: '◕', m: 'ω' }
52
+ }
53
+ }
54
+
55
+ /** The pig, with a face that follows the mood. */
56
+ export function portrait(stage, currentMood) {
57
+ if (stage.key === 'box') {
58
+ return [
59
+ ' 🥚',
60
+ ' ╭───────╮',
61
+ ' │ 〜〜 │',
62
+ ' ╰───────╯',
63
+ ]
64
+ }
65
+ const f = face(currentMood)
66
+ const hat = currentMood.key === 'working' ? ' 💼' : currentMood.key === 'dead' ? ' 💀' : ''
67
+ return [
68
+ ` ${stage.emoji}${hat}`,
69
+ ' ╭───────╮',
70
+ ` │ ${f.l} ${f.r} │`,
71
+ ` │ ${f.m} │`,
72
+ ' ╰───────╯',
73
+ ]
74
+ }
75
+
76
+ function xpLine(state) {
77
+ const progress = levelProgress(state.xp)
78
+ return progress.maxed
79
+ ? `✨ 成长 ${Math.floor(state.xp)} Lv.${progress.level}(满级)`
80
+ : `✨ 成长 ${Math.floor(state.xp)} Lv.${progress.level} · 还差 ${Math.ceil(progress.toNext)} 升级`
81
+ }
82
+
83
+ function statusLine(state, nowMs) {
84
+ if (state.dead) return '💀 状态 已经走了 · 用 ' + REVIVE_ITEM.label + ' 可以救回来'
85
+ if (state.activity !== null) {
86
+ const left = activitySecondsLeft(state, nowMs)
87
+ const kind = state.activity.kind === 'study' ? '上课' : state.activity.kind === 'trip' ? '旅行' : '打工'
88
+ return `${state.activity.emoji} ${kind} ${state.activity.label}中 · 还有 ${left} 秒`
89
+ }
90
+ const ill = state.illness === null ? null : illnessAt(state.illness.chain, state.illness.stage)
91
+ if (ill !== null) return `🤒 生病 ${ill.name}(第 ${ill.stage}/4 期)· 需要「${ill.cure}」`
92
+ return null
93
+ }
94
+
95
+ /** The three QQ Pet traits, as one line. */
96
+ function traitsLine(state) {
97
+ const traits = traitView(state)
98
+ return TRAIT_ORDER
99
+ .map(key => `${TRAITS[key].emoji} ${TRAITS[key].label} ${traits[key]}`)
100
+ .join(' ')
101
+ }
102
+
103
+ function memoriesBlock(state) {
104
+ if (state.memories.length === 0) return []
105
+ return ['', '🕘 最近', ...state.memories.slice(-4).map(line => ` ${line}`)]
106
+ }
107
+
108
+ /** The full `/pig` card. */
109
+ export function renderStatus(state, nowMs) {
110
+ const stage = formStageView(state, nowMs)
111
+ const current = mood(state, nowMs)
112
+ const special = statusLine(state, nowMs)
113
+ const days = (nowMs - state.bornAt) / 86_400_000
114
+ const age = state.hatched !== true ? '还没拆开' : (days < 1 ? '今天刚出生' : `${Math.floor(days)} 天大`)
115
+ const lines = [
116
+ `${stage.emoji} ${state.name} ${stage.label} · ${age} ${current.emoji} ${current.label}`,
117
+ RULE,
118
+ `🍚 饱食 ${bar(state.satiety)} ${Math.round(state.satiety)}`,
119
+ `❤️ 心情 ${bar(state.happiness)} ${Math.round(state.happiness)}`,
120
+ `🫧 清洁 ${bar(state.cleanliness)} ${Math.round(state.cleanliness)}`,
121
+ `💚 健康 ${bar(healthPercent(state))} ${state.health}/${MAX.health}`,
122
+ `🪙 金币 ${state.coins}`,
123
+ `⚖️ 体重 ${formatWeight(state.weightG)}`,
124
+ xpLine(state),
125
+ traitsLine(state),
126
+ ]
127
+ if (special !== null) lines.push(RULE, special)
128
+ lines.push(
129
+ RULE,
130
+ ...portrait(stage, current),
131
+ ` 「${stage.line}」`,
132
+ RULE,
133
+ '🍽 完成回合 +5 · 发消息 +2 · 工具调用 +3 · 报错也长肉',
134
+ '👆 右下角的猪点着就能养:喂食 · 洗澡 · 玩耍 · 打工 · 商店',
135
+ )
136
+ return [...lines, ...memoriesBlock(state)].join('\n')
137
+ }
138
+
139
+ /** The hatching ceremony. */
140
+ export function renderHatch(state, nowMs) {
141
+ const stage = formStageView(state, nowMs)
142
+ return [
143
+ ' 纸盒打开了 ——',
144
+ '',
145
+ ...portrait(stage, mood(state, nowMs)),
146
+ '',
147
+ `✨ ${stage.emoji} ${state.name} 蹦了出来(${stage.label})`,
148
+ ` 「${stage.line}」`,
149
+ '',
150
+ `初始盘缠:🪙 ${state.coins} 金币。`,
151
+ '正常干活就能养活它;右下角的猪点一下就能喂食、洗澡、送去打工。',
152
+ ].join('\n')
153
+ }
154
+
155
+ /** One care action's outcome card. */
156
+ export function renderAction(state, nowMs, action, crossed) {
157
+ const spec = ACTIONS[action]
158
+ if (spec === undefined) return '🐖 不认识这个动作。'
159
+ const current = mood(state, nowMs)
160
+ const lines = [
161
+ `${state.name} ${spec.verb}`,
162
+ '',
163
+ ...portrait(formStageView(state, nowMs), current),
164
+ '',
165
+ `🍚 饱食 ${bar(state.satiety)} ${Math.round(state.satiety)}`,
166
+ `❤️ 心情 ${bar(state.happiness)} ${Math.round(state.happiness)}`,
167
+ `🫧 清洁 ${bar(state.cleanliness)} ${Math.round(state.cleanliness)}`,
168
+ ]
169
+ const wait = actionCooldownSeconds(state, action, nowMs)
170
+ if (wait > 0) lines.push('', `(${wait} 秒后还能再来一次)`)
171
+ return lines.join('\n')
172
+ }
173
+
174
+ /** The refusal card when a care action is still cooling down. */
175
+ export function renderTooSoon(state, nowMs, action) {
176
+ const spec = ACTIONS[action]
177
+ if (spec === undefined) return '🐖 不认识这个动作。'
178
+ const wait = actionCooldownSeconds(state, action, nowMs)
179
+ const hints = {
180
+ feed: `${state.name} 摆摆手:刚吃过,肚子还圆着呢。`,
181
+ bathe: `${state.name} 缩了缩:刚洗完,香着呢,别再冲水了。`,
182
+ play: `${state.name} 喘着气:让我歇会儿,刚玩过。`,
183
+ pet: `${state.name} 还没缓过来。`,
184
+ }
185
+ return [hints[action] ?? `${state.name} 现在不想动。`, '', `${wait} 秒后可以再${spec.label}。`].join('\n')
186
+ }
187
+
188
+ /** Sent the pig out to work. */
189
+ export function renderWorkReport(state, nowMs, job) {
190
+ return [
191
+ `${state.name} 背上小包出门了 ${job.emoji}`,
192
+ '',
193
+ `💼 工作 ${job.label}`,
194
+ `⏱ 时长 ${job.minutes} 分钟`,
195
+ `🪙 报酬 ${job.coins} 金币`,
196
+ `🍚 消耗 饱食 ${job.satiety} · 清洁 ${job.cleanliness}`,
197
+ '',
198
+ `预计 ${activitySecondsLeft(state, nowMs)} 秒后回来。这期间不能喂食洗澡 —— 它在外面忙着。`,
199
+ ].join('\n')
200
+ }
201
+
202
+ /** Sent the pig to class. */
203
+ export function renderStudyReport(state, nowMs, subject, stage) {
204
+ const level = courseView(state)[subject.key] ?? 0
205
+ return [
206
+ `${state.name} 背上书包去上课了 ${subject.emoji}`,
207
+ '',
208
+ `📚 课程 ${subject.label}(${stage.label})`,
209
+ `⏱ 时长 ${stage.minutes} 分钟`,
210
+ `🪙 学费 ${stage.tuition} 金币`,
211
+ `📈 收获 ${TRAITS[subject.trait].label} +${stage.gain} · 成长 +${STUDY_GROWTH_PER_LESSON}`,
212
+ `🍚 消耗 饱食 ${stage.satiety} · 心情 ${stage.happiness}`,
213
+ '',
214
+ `这是${subject.label}第 ${level + 1} 节。预计 ${activitySecondsLeft(state, nowMs)} 秒后下课。`,
215
+ ].join('\n')
216
+ }
217
+
218
+ /** Sent the pig travelling. */
219
+ export function renderTripReport(state, nowMs, trip) {
220
+ return [
221
+ `${state.name} 拖着小行李箱出发了 ${trip.emoji}`,
222
+ '',
223
+ `🧳 目的地 ${trip.label}`,
224
+ `⏱ 时长 ${trip.minutes} 分钟`,
225
+ `🪙 花费 ${trip.cost} 金币`,
226
+ `❤️ 心情 +${trip.happiness} · 成长 +${outingGrowth(trip.minutes)}`,
227
+ `🍚 消耗 饱食 ${trip.satiety}`,
228
+ '',
229
+ `会带回一件纪念品。预计 ${activitySecondsLeft(state, nowMs)} 秒后回来。`,
230
+ ].join('\n')
231
+ }
232
+
233
+ /** A refusal from the work path, with the reason spelled out. */
234
+ export function renderWorkRefusal(state, text) {
235
+ return [
236
+ `💼 出不了门`,
237
+ '',
238
+ text,
239
+ '',
240
+ `现在:${mood(state, Date.now()).emoji} ${mood(state, Date.now()).label} · 🍚 ${Math.round(state.satiety)} · 🪙 ${state.coins}`,
241
+ ].join('\n')
242
+ }
243
+
244
+ /** Bought something. */
245
+ export function renderBuy(state, result, item) {
246
+ if (result?.ok !== true) {
247
+ if (result?.reason === 'poor') return `🪙 钱不够:${item.emoji} ${item.label} 要 ${item.price} 金币,你只有 ${state?.coins ?? 0}。`
248
+ if (result?.reason === 'dead') return `${state?.name ?? '猪'} 已经走了…先救回来再买东西。`
249
+ return `🐖 没能买下 ${item.label}。`
250
+ }
251
+ return [
252
+ `🛒 买到了 ${item.emoji} ${item.label}(-${item.price} 金币)`,
253
+ '',
254
+ `🪙 余额 ${state.coins}`,
255
+ `🎒 背包 ${item.label} ×${state.inventory?.[item.key] ?? 0}`,
256
+ '',
257
+ `用起来:/pig use ${item.key}`,
258
+ ].join('\n')
259
+ }
260
+
261
+ /** Used something from the backpack. */
262
+ export function renderUse(state, result, item) {
263
+ if (result?.ok !== true) {
264
+ switch (result?.reason) {
265
+ case 'empty': return `🎒 背包里没有 ${item.emoji} ${item.label}。先去 /pig shop 买。`
266
+ case 'not-sick': return `${state?.name ?? '猪'} 现在没生病,吃药没用。`
267
+ case 'not-dead': return `${state?.name ?? '猪'} 活得好好的,用不上 ${REVIVE_ITEM.label}。`
268
+ case 'dead': return `${state?.name ?? '猪'} 已经走了…只有 ${REVIVE_ITEM.label} 能救回来。`
269
+ case 'wrong-medicine': {
270
+ const need = result.needs
271
+ return `💊 药不对症。${state?.name ?? '猪'} 现在需要的是「${need?.label ?? '别的药'}」。`
272
+ }
273
+ case 'working': return `${state?.name ?? '猪'} 在外面打工,回来再吃。`
274
+ default: return `🐖 没能用上 ${item.label}。`
275
+ }
276
+ }
277
+ const lines = [`${state.name} 用了 ${item.emoji} ${item.label}`]
278
+ if (item.kind === 'medicine') lines.push('', '💚 病好了!健康恢复满值。')
279
+ else if (item.kind === 'revive') lines.push('', '✨ 回来了!等级、成长值和金币都还在。')
280
+ else {
281
+ lines.push(
282
+ '',
283
+ `🍚 饱食 ${bar(state.satiety)} ${Math.round(state.satiety)}`,
284
+ `❤️ 心情 ${bar(state.happiness)} ${Math.round(state.happiness)}`,
285
+ `🫧 清洁 ${bar(state.cleanliness)} ${Math.round(state.cleanliness)}`,
286
+ )
287
+ }
288
+ return lines.join('\n')
289
+ }
290
+
291
+ /** The scales. */
292
+ export function renderWeigh(state, nowMs) {
293
+ const stage = formStageView(state, nowMs)
294
+ const kilos = state.weightG / 1000
295
+ let verdict = '还算苗条,继续保持'
296
+ if (kilos >= 40) verdict = '这已经是一头正经的猪了'
297
+ else if (kilos >= 15) verdict = '抱起来有点费劲'
298
+ else if (kilos >= 6) verdict = '手感很好,沉甸甸的'
299
+ else if (kilos >= 3) verdict = '圆润,但还能抱得动'
300
+ return [
301
+ `⚖️ ${state.name} 站上了秤`,
302
+ '',
303
+ ` ${stage.emoji}`,
304
+ ` ${formatWeight(state.weightG)}`,
305
+ '',
306
+ ` 「${verdict}」`,
307
+ ].join('\n')
308
+ }
309
+
310
+ /** `/pig about`. */
311
+ export function renderAbout(commandName) {
312
+ const actions = ACTION_ORDER.map(key => ` ${ACTIONS[key].emoji} ${ACTIONS[key].label}`).join(' · ')
313
+ const jobs = JOBS.map(job => ` ${job.emoji} ${job.label}(Lv.${job.requires.level} · ${job.minutes} 分钟 · ${job.coins} 金币)`).join('\n')
314
+ const courses = SUBJECTS.map(s => `${s.emoji}${s.label}`).join(' ')
315
+ const stages = SCHOOL_STAGES.map(s => ` ${s.label}${Number.isFinite(s.upTo) ? `(到第 ${s.upTo} 节)` : ''}:${s.minutes} 分钟 · 学费 ${s.tuition} · +${s.gain}`).join('\n')
316
+ const trips = TRIPS.map(t => ` ${t.emoji} ${t.label}(${t.minutes} 分钟 · ${t.cost} 金币)`).join('\n')
317
+ const shop = SHOP.map(item => ` ${item.emoji} ${item.label} ${String(item.price).padStart(3)} 金币`).join('\n')
318
+ return [
319
+ '🐖 dsh-piggy —— 一只住在 DSH 里的猪',
320
+ RULE,
321
+ '最省事的用法:点右下角的 🐖,面板上六个图标点着用。',
322
+ ' ① 状态 ② 学习 ③ 打工 ④ 商店 ⑤ 旅行 ⑥ 背包',
323
+ RULE,
324
+ `照顾:${actions}`,
325
+ '',
326
+ '📚 学习(九门课各算各的课时,课时决定能干什么工作):',
327
+ ` ${courses}`,
328
+ stages,
329
+ '',
330
+ '💼 打工(出门赚钱):',
331
+ jobs,
332
+ '',
333
+ '🧳 旅行(带回纪念品):',
334
+ trips,
335
+ '',
336
+ '🛒 商店:',
337
+ shop,
338
+ '',
339
+ '🤒 生病:饿着或脏着太久会得病,健康上限 5。必须对症下药;',
340
+ ` 健康归零就没了,用 ${REVIVE_ITEM.label} 救回来(保留等级、金币和收藏)。`,
341
+ RULE,
342
+ '命令(不想点鼠标时才用):',
343
+ `/${commandName} · hatch · feed · bathe · play · pet`,
344
+ `/${commandName} study <科目> <小学|大学|研究生>`,
345
+ `/${commandName} work <odd|site|office> · trip <suburb|mountain|sea|abroad> · calloff`,
346
+ `/${commandName} shop · buy <物品> · use <物品>`,
347
+ `/${commandName} crown [形态](长成后加冕)· weigh · name <名字> · about`,
348
+ RULE,
349
+ '它不调用模型、不注入上下文、不花一个 token。',
350
+ '存档在 $DSH_HOME/dsh-piggy/state.json。',
351
+ ].join('\n')
352
+ }
353
+
354
+ /** The empty-house prompt. */
355
+ export function renderNoPig(commandName) {
356
+ return `这里还没有猪。/${commandName} hatch 孵一只 🥚 —— 它会吃你之后的真实工作长大。`
357
+ }
package/routes.js ADDED
@@ -0,0 +1,201 @@
1
+ // @ts-check
2
+ /**
3
+ * HTTP 路由:面板读的快照、面板发的动作,以及手绘精灵图。
4
+ *
5
+ * 路由只做分发与序列化,不算业务规则 —— 每个动作都转发给 store(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/routes
7
+ */
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ import { snapshot } from './snapshot.js'
11
+
12
+ const STATE_ROUTE = '/dsh-piggy/state'
13
+ const ACT_ROUTE = '/dsh-piggy/act'
14
+ const ART_ROUTE = '/dsh-piggy/art'
15
+ const BODY_LIMIT_BYTES = 2048
16
+
17
+ const str = value => (typeof value === 'string' ? value : '')
18
+
19
+ function sendJson(res, status, body, extra = {}) {
20
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extra })
21
+ res.end(JSON.stringify(body))
22
+ }
23
+
24
+ async function readJsonBody(req) {
25
+ let text = ''
26
+ for await (const chunk of req) {
27
+ text += chunk
28
+ if (text.length > BODY_LIMIT_BYTES) return null
29
+ }
30
+ if (text.trim() === '') return {}
31
+ try {
32
+ const parsed = JSON.parse(text)
33
+ return typeof parsed === 'object' && parsed !== null ? parsed : null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Every operation the panel can invoke. One table means the HTTP route and the
41
+ * slash command cannot drift apart.
42
+ */
43
+ const OPERATIONS = {
44
+ hatch: store => ({ ok: true, hatched: store.hatch() }),
45
+ adopt: store => ({ ok: store.adopt(), adopted: true }),
46
+ reset: store => ({ ok: store.reset(), reset: true }),
47
+ dev: (store, body) => ({ ok: store.dev(body.patch ?? {}), dev: true }),
48
+ ageFromNow: store => ({ ok: store.ageFromNow(), ageFromNow: true }),
49
+ timeScale: (store, body) => ({ ok: store.setTimeScale(body.scale), timeScale: true }),
50
+ // The three care actions spend an item; `item` says which one.
51
+ feed: (store, body) => store.act('feed', str(body.item)),
52
+ bathe: (store, body) => store.act('bathe', str(body.item)),
53
+ play: (store, body) => store.act('play', str(body.item)),
54
+ pet: store => store.act('pet'),
55
+ // Answer the pig's latest line: `line` is the message id, `index` the button.
56
+ reply: (store, body) => store.reply(Number(body.line), Number(body.index)),
57
+ // 日常:签到(在线礼包在 B5 的第二步接上)。
58
+ signIn: store => store.signIn(),
59
+ openGift: store => store.openGift(),
60
+ // The panel's timers ask the pig to speak up; the pig decides whether to.
61
+ chat: (store, body) => store.chat(str(body.reason)),
62
+ quiet: (store, body) => store.setQuiet(body.on === true),
63
+ owner: (store, body) => store.setOwnerName(str(body.name)),
64
+ // 居民卡: the catchphrase and the motto.
65
+ catchphrase: (store, body) => store.setCatchphrase(str(body.text)),
66
+ motto: (store, body) => store.setMotto(str(body.text)),
67
+ // 加冕: `form` picks which one (empty = the first).
68
+ crown: (store, body) => store.crown(str(body.form)),
69
+ // 改猪的名字(和斜杠命令 /pig name 同一条路)。
70
+ name: (store, body) => ({ ok: Boolean(store.rename(str(body.name))), name: true }),
71
+ work: (store, body) => store.startWork(str(body.job)),
72
+ study: (store, body) => store.startStudy(str(body.subject), str(body.stage)),
73
+ interest: (store, body) => store.startInterest(str(body.interest)),
74
+ trip: (store, body) => store.startTrip(str(body.trip)),
75
+ calloff: store => store.callOffActivity(),
76
+ buy: (store, body) => store.buy(str(body.item)),
77
+ use: (store, body) => store.useItem(str(body.item)),
78
+ doctor: store => store.seeDoctor(),
79
+ // Souvenirs are the only thing the pig can sell back.
80
+ sell: (store, body) => store.sellSouvenir(str(body.souvenir)),
81
+ // 家当: put a dress item on / take it off.
82
+ wear: (store, body) => store.wear(str(body.item), body.on !== false),
83
+ // Debug: one of everything.
84
+ giveAll: store => store.grantAll(),
85
+ }
86
+
87
+ /** GET /dsh-piggy/state — the one shape the panel reads. */
88
+ function registerStateRoute(webServer, store) {
89
+ return webServer.register({
90
+ kind: 'exact',
91
+ path: STATE_ROUTE,
92
+ handler: async (req, res) => {
93
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed; use GET' }, { allow: 'GET' })
94
+ try {
95
+ sendJson(res, 200, snapshot(store), { 'cache-control': 'no-store' })
96
+ } catch (error) {
97
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
98
+ }
99
+ },
100
+ })
101
+ }
102
+
103
+ /**
104
+ * GET /dsh-piggy/art/<name>.svg — the hand-drawn sprites.
105
+ *
106
+ * Serving them from the package keeps the art as real .svg files in the
107
+ * repository rather than a blob embedded in the client bundle.
108
+ */
109
+ function registerArtRoute(webServer) {
110
+ return webServer.register({
111
+ kind: 'prefix',
112
+ path: ART_ROUTE,
113
+ handler: (req, res) => {
114
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed; use GET' }, { allow: 'GET' })
115
+ const raw = String(req.url ?? '').split('?')[0]
116
+ const name = raw.startsWith(ART_ROUTE + '/') ? raw.slice(ART_ROUTE.length + 1) : ''
117
+ // Only the files this package ships: a fixed, boring name pattern, so
118
+ // nothing from the request can ever walk out of ./assets.
119
+ if (!/^[a-z][a-z0-9-]{0,31}\.svg$/.test(name)) return sendJson(res, 404, { error: 'not found' })
120
+ try {
121
+ const svg = readFileSync(new URL('./assets/' + name, import.meta.url))
122
+ res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-cache' })
123
+ res.end(svg)
124
+ } catch (error) {
125
+ console.warn(`[dsh-piggy] sprite missing: name="${name}" reason="${error instanceof Error ? error.message : String(error)}"`)
126
+ sendJson(res, 404, { error: 'not found' })
127
+ }
128
+ },
129
+ })
130
+ }
131
+
132
+ /**
133
+ * POST /dsh-piggy/act — one action from the panel.
134
+ *
135
+ * The operation's verdict must win over the snapshot's always-true `ok`:
136
+ * spreading the snapshot last silently swallowed every refusal.
137
+ */
138
+ function registerActRoute(webServer, store) {
139
+ return webServer.register({
140
+ kind: 'exact',
141
+ path: ACT_ROUTE,
142
+ handler: async (req, res) => {
143
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed; use POST' }, { allow: 'POST' })
144
+ const body = await readJsonBody(req)
145
+ if (body === null) return sendJson(res, 413, { error: 'body too large or not JSON' })
146
+ const operation = typeof body.action === 'string' ? body.action : ''
147
+ const run = Object.hasOwn(OPERATIONS, operation) ? OPERATIONS[operation] : null
148
+ if (run === null) {
149
+ return sendJson(res, 400, { error: `unknown action "${operation}"`, allowed: Object.keys(OPERATIONS) })
150
+ }
151
+ // A throwing operation must answer, not take the route down with it: an
152
+ // unhandled error here would leave the panel polling a dead handler.
153
+ try {
154
+ const result = run(store, body)
155
+ sendJson(res, 200, {
156
+ ...snapshot(store),
157
+ ok: result.ok !== false,
158
+ reason: result.reason,
159
+ wait: result.wait,
160
+ price: result.price,
161
+ missing: result.missing,
162
+ sold: result.sold,
163
+ need: result.need,
164
+ have: result.have,
165
+ }, { 'cache-control': 'no-store' })
166
+ } catch (error) {
167
+ console.warn(`[dsh-piggy] action failed: action="${operation}" reason="${error instanceof Error ? error.message : String(error)}"`)
168
+ sendJson(res, 500, { ok: false, reason: 'error' })
169
+ }
170
+ },
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Register the three routes once the web seam exists.
176
+ *
177
+ * `ctx.inject` waits for the service instead of sampling it. The previous
178
+ * version read `ctx.get('webServer')` at apply time and bailed out when it was
179
+ * undefined — which is exactly what happened during profile activation, so the
180
+ * routes were never registered and the panel polled a 404 forever. A silent
181
+ * optionality guard is not the same thing as a tolerant one: this one still
182
+ * degrades on a host with no web seam, but only *after* the service has
183
+ * actually been waited for.
184
+ */
185
+ export function registerRoutes(ctx, store) {
186
+ ctx.inject(['webServer'], (webCtx) => {
187
+ const webServer = webCtx.webServer
188
+ if (webServer === undefined) return () => {}
189
+ const disposers = []
190
+ try {
191
+ disposers.push(registerStateRoute(webServer, store))
192
+ disposers.push(registerArtRoute(webServer))
193
+ disposers.push(registerActRoute(webServer, store))
194
+ } catch (error) {
195
+ // A route already taken: the pig stays command-only rather than breaking
196
+ // activation, but this is a real failure and should be visible.
197
+ console.warn(`[dsh-piggy] 路由注册失败,猪只能用命令访问:${error instanceof Error ? error.message : String(error)}`)
198
+ }
199
+ return () => { for (const dispose of disposers) { try { dispose() } catch { /* best effort */ } } }
200
+ })
201
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * 打包客户端插件。
5
+ *
6
+ * DSH 的客户端插件是按 classic <script src> 加载的**单文件**(见
7
+ * packages/client/modules/src/client/system.ts 的 loadBundle),源码拆分之后
8
+ * 必须有这一步;产物 client.js 提交入库,由 test/bundle.test.js 守住新鲜度。
9
+ *
10
+ * 用法:node scripts/build-client.mjs
11
+ */
12
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs'
13
+ import { createRequire } from 'node:module'
14
+ import { dirname, join, resolve } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
18
+ const entryPoint = join(packageRoot, 'src/client/index.js')
19
+ const outfile = join(packageRoot, 'client.js')
20
+ const require = createRequire(import.meta.url)
21
+
22
+ /**
23
+ * Find esbuild, in order: an installed dependency, an explicit override, then a
24
+ * checkout that happens to have it in the pnpm store.
25
+ *
26
+ * The override exists because this environment has no registry access; the
27
+ * documented, portable way is `pnpm add -D esbuild`.
28
+ * @returns {any} the esbuild module
29
+ */
30
+ function loadEsbuild() {
31
+ try {
32
+ return require('esbuild')
33
+ } catch {
34
+ // Not installed locally: fall through to the explicit locations below.
35
+ }
36
+
37
+ const explicit = process.env.DSH_PIG_ESBUILD
38
+ if (explicit !== undefined && explicit.trim() !== '') {
39
+ return require(explicit.trim())
40
+ }
41
+
42
+ // A sibling checkout (…/DSH/deepseek-harness next to …/DSH/workspaces/<pkg>)
43
+ // keeps this working in the author's environment without an install step.
44
+ const candidates = []
45
+ const stores = [
46
+ join(packageRoot, 'node_modules/.pnpm'),
47
+ resolve(packageRoot, '../../deepseek-harness/node_modules/.pnpm'),
48
+ ]
49
+ for (const store of stores) {
50
+ if (!existsSync(store)) continue
51
+ for (const entry of readdirSync(store)) {
52
+ if (!entry.startsWith('esbuild@')) continue
53
+ const candidate = join(store, entry, 'node_modules/esbuild')
54
+ if (existsSync(candidate)) candidates.push(candidate)
55
+ }
56
+ }
57
+ if (candidates.length === 0) {
58
+ throw new Error(
59
+ 'build-client: esbuild not found. Install it with `pnpm add -D esbuild`, '
60
+ + 'or point DSH_PIG_ESBUILD at an esbuild entry.',
61
+ )
62
+ }
63
+ return require(candidates[candidates.length - 1])
64
+ }
65
+
66
+ /** @returns {Promise<string>} the bundle text */
67
+ export async function buildClient() {
68
+ const esbuild = loadEsbuild()
69
+ const result = await esbuild.build({
70
+ entryPoints: [entryPoint],
71
+ bundle: true,
72
+ format: 'iife',
73
+ platform: 'browser',
74
+ // No minify: the shipped file stays readable, and the freshness test compares
75
+ // bytes, so any transform change is visible in review.
76
+ minify: false,
77
+ legalComments: 'none',
78
+ absWorkingDir: packageRoot,
79
+ write: false,
80
+ logLevel: 'warning',
81
+ banner: {
82
+ js: '// GENERATED FILE. Edit src/client/ and run `npm run build`; do not edit by hand.',
83
+ },
84
+ })
85
+ return result.outputFiles[0].text
86
+ }
87
+
88
+ const invokedDirectly = process.argv[1] !== undefined
89
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
90
+
91
+ if (invokedDirectly) {
92
+ try {
93
+ const text = await buildClient()
94
+ writeFileSync(outfile, text)
95
+ console.log(`build-client: wrote ${outfile} (${text.length} bytes)`)
96
+ } catch (error) {
97
+ console.error(`build-client failed: ${error instanceof Error ? error.message : String(error)}`)
98
+ process.exitCode = 1
99
+ }
100
+ }