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
@@ -0,0 +1,308 @@
1
+ // @ts-check
2
+ /**
3
+ * 时间推进:衰减、疾病、结算、死亡。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/settlement
7
+ */
8
+
9
+ import { CERTIFICATE_AFTER, DEFAULT_TIME_SCALE, ILLNESS_ONSET, GRADUATION_GROWTH, GRADUATION_LESSONS, SICK_AWAY_MULTIPLIER, SICK_PAY_MULTIPLIER, STUDY_GROWTH_PER_LESSON, TRAITS, illnessStageMs, interestByKey, jobByKey, rarityByKey, schoolStageByKey, stageForNextLesson, subjectByKey, traitBonus, tripByKey } from '../data.js'
10
+ import { AWAY_DECAY_MULTIPLIER, AWAY_FLOOR, CLEANLINESS_DECAY_PER_MIN, HAPPINESS_DECAY_PER_MIN, SATIETY_DECAY_PER_MIN, SETTLE_STEP_MS } from './constants.js'
11
+ import { announce, clamp100, remember } from './effects.js'
12
+ import { growWithTime, grow, outingGrowth } from './growth.js'
13
+ import { describeDrops, graduationDrops, studyDrops, workDrops } from './drops.js'
14
+ import { advanceIllness, noteOuting, restAtHome, rollForIllness } from './illness.js'
15
+ import { say } from './lines.js'
16
+ import { rollerFor } from './random.js'
17
+ import { noteToday } from './diary.js'
18
+
19
+ export { currentIllness, die } from './illness.js'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // The clock: decay, activity settlement, illness progression
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Bring the pig up to `nowMs`, resolving everything that happened in between.
27
+ *
28
+ * The gap is cut into at most two stretches — away until the activity ends,
29
+ * then home — and each stretch is walked in steps of SETTLE_STEP_MS. That is
30
+ * what lets a long absence play out the way it would have live: a job that
31
+ * ended at 18:56 is paid (and stamped) at 18:56, the evening after it is time
32
+ * at home, and a bar that crosses a threshold at 3 a.m. starts its sickness
33
+ * risk at 3 a.m. rather than at whatever moment the panel was next opened.
34
+ *
35
+ * Randomness comes from the pig's own seed (see random.js); pass `roll` to pin
36
+ * the outcome, as the tests and dev mode do.
37
+ *
38
+ * @param {object} state
39
+ * @param {number} nowMs
40
+ * @param {{roll?: import('./random.js').Roll}} [options]
41
+ */
42
+ export function decay(state, nowMs, options = {}) {
43
+ const fromMs = state.lastSeenAt ?? nowMs
44
+ state.lastSeenAt = nowMs
45
+ // An unopened box is not a pet yet: it never gets hungry, dirty, sick or
46
+ // older, and it cannot die while it waits to be poked open (#2).
47
+ if (state.hatched !== true) return state
48
+ if (!(nowMs > fromMs) || state.dead === true) return state
49
+ const next = options.roll ?? rollerFor(state)
50
+ if (typeof state.ageMs !== 'number' || !Number.isFinite(state.ageMs)) {
51
+ // First tick after loading an old save: seed pig time from the birthday.
52
+ state.ageMs = typeof state.bornAt === 'number' ? Math.max(0, fromMs - state.bornAt) : 0
53
+ }
54
+
55
+ let cursor = fromMs
56
+ const activity = state.activity
57
+ if (activity !== null && activity !== undefined) {
58
+ const untilMs = Math.min(nowMs, Math.max(cursor, activity.endsAt))
59
+ passTime(state, { fromMs: cursor, toMs: untilMs, away: true }, next)
60
+ cursor = untilMs
61
+ // Settle at the moment it ended, not at the moment someone looked.
62
+ if (state.dead !== true && state.activity === activity && untilMs >= activity.endsAt) {
63
+ finishActivity(state, untilMs, next)
64
+ }
65
+ }
66
+ if (state.dead !== true) passTime(state, { fromMs: cursor, toMs: nowMs, away: false }, next)
67
+ return state
68
+ }
69
+
70
+ /**
71
+ * Walk one stretch, all of it either away or at home, in bounded steps.
72
+ * @param {object} state
73
+ * @param {{fromMs: number, toMs: number, away: boolean}} stretch
74
+ * @param {import('./random.js').Roll} next
75
+ */
76
+ function passTime(state, stretch, next) {
77
+ let cursor = stretch.fromMs
78
+ while (cursor < stretch.toMs && state.dead !== true) {
79
+ const stepEnd = Math.min(stretch.toMs, cursor + SETTLE_STEP_MS)
80
+ step(state, { elapsedMs: stepEnd - cursor, atMs: stepEnd, away: stretch.away }, next)
81
+ cursor = stepEnd
82
+ }
83
+ }
84
+
85
+ /**
86
+ * One step of pig time: bars, sickness risk, illness, age.
87
+ * @param {object} state
88
+ * @param {{elapsedMs: number, atMs: number, away: boolean}} tick
89
+ * @param {import('./random.js').Roll} next
90
+ */
91
+ function step(state, tick, next) {
92
+ const { elapsedMs, atMs, away } = tick
93
+ drainBars(state, elapsedMs / 60000, away)
94
+ trackSicknessRisk(state, elapsedMs / 60000, away, atMs, next)
95
+ progressIllness(state, elapsedMs, { away, atMs }, next)
96
+ if (state.dead !== true) growOlder(state, elapsedMs, atMs)
97
+ }
98
+
99
+ /**
100
+ * Time passing, with one floor: an activity may not empty a bar on its own.
101
+ * A pig back from a day trip should be ravenous and filthy — a welcome-home
102
+ * meal and bath — not sitting at zero before you can even react to it.
103
+ * Going away never *raises* a bar either.
104
+ */
105
+ function drainBars(state, minutes, away) {
106
+ const speed = away ? AWAY_DECAY_MULTIPLIER : 1
107
+ const drain = (value, perMinute) => {
108
+ const lowered = value - minutes * perMinute * speed
109
+ return away ? Math.max(lowered, Math.min(value, AWAY_FLOOR)) : lowered
110
+ }
111
+ state.satiety = clamp100(drain(state.satiety, SATIETY_DECAY_PER_MIN))
112
+ state.happiness = clamp100(drain(state.happiness, HAPPINESS_DECAY_PER_MIN))
113
+ state.cleanliness = clamp100(drain(state.cleanliness, CLEANLINESS_DECAY_PER_MIN))
114
+ }
115
+
116
+ /**
117
+ * Illness only comes from being left at home. A pig that was out living its
118
+ * life has not been neglected, and coming back sick every trip is not a game.
119
+ * At home each condition adds its own hourly chance (see illness.js).
120
+ */
121
+ function trackSicknessRisk(state, minutes, away, atMs, next) {
122
+ if (away) return
123
+ restAtHome(state, minutes)
124
+ rollForIllness(state, { minutes, atMs }, next)
125
+ }
126
+
127
+ /**
128
+ * Illness advances on accumulated *effective* time, not wall clock: being out
129
+ * and about while ill runs it at SICK_AWAY_MULTIPLIER, so a day of work costs
130
+ * two days of illness and staying home is the cheap way to wait it out.
131
+ */
132
+ function progressIllness(state, elapsedMs, where, next) {
133
+ if (state.illness === null || state.illness === undefined) return
134
+ const rate = where.away ? SICK_AWAY_MULTIPLIER : 1
135
+ state.illness.progressMs = (state.illness.progressMs ?? 0) + elapsedMs * rate
136
+ let guard = 0
137
+ while (state.illness !== null && guard < 16) {
138
+ // Each stage has its own length, so it has to be re-read after every step.
139
+ const stageMs = illnessStageMs(state.illness.stage)
140
+ if (state.illness.progressMs < stageMs) break
141
+ // Carry the excess into the next stage rather than dropping it.
142
+ const carried = state.illness.progressMs - stageMs
143
+ advanceIllness(state, where.atMs, next)
144
+ if (state.illness !== null) state.illness.progressMs = carried
145
+ guard += 1
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Pig time is elapsed real time times the scale. Age only counts days now —
151
+ * the body follows the level — and the same pig time drives passive growth.
152
+ */
153
+ function growOlder(state, elapsedMs, atMs) {
154
+ const scale = Number.isFinite(state.timeScale) && state.timeScale > 0
155
+ ? state.timeScale
156
+ : DEFAULT_TIME_SCALE
157
+ const pigMs = elapsedMs * scale
158
+ state.ageMs += pigMs
159
+ if (state.hatched === true) growWithTime(state, pigMs, atMs)
160
+ }
161
+
162
+ /**
163
+ * @param {object} state
164
+ * @param {number} nowMs
165
+ * @param {import('./random.js').Roll} [next] - for drops; defaults to the pig's own seed.
166
+ */
167
+ export function finishActivity(state, nowMs, next = rollerFor(state)) {
168
+ const activity = state.activity
169
+ state.activity = null
170
+ if (activity === null) return
171
+ state.lastActiveAt = nowMs
172
+ if (activity.kind === 'work') finishWork(state, activity, nowMs, next)
173
+ else if (activity.kind === 'study') finishStudy(state, activity, nowMs, next)
174
+ else if (activity.kind === 'interest') finishInterest(state, activity, nowMs)
175
+ else if (activity.kind === 'trip') finishTrip(state, activity, nowMs)
176
+ }
177
+
178
+ export function finishInterest(state, activity, nowMs) {
179
+ const interest = interestByKey(activity.key)
180
+ if (interest === null) return
181
+ // Straight into the same three traits the school ladder feeds.
182
+ state.traits = { ...(state.traits ?? {}) }
183
+ state.traits[interest.trait] = (state.traits[interest.trait] ?? 0) + interest.gain
184
+ state.interests = { ...(state.interests ?? {}) }
185
+ state.interests[interest.key] = (state.interests[interest.key] ?? 0) + 1
186
+ const certified = state.interests[interest.key] === CERTIFICATE_AFTER
187
+ state.satiety = clamp100(state.satiety - 4)
188
+ state.happiness = clamp100(state.happiness + 3)
189
+ state.stats.interests = (state.stats.interests ?? 0) + 1
190
+ noteOuting(state)
191
+ grow(state, STUDY_GROWTH_PER_LESSON, nowMs)
192
+ remember(state, `${interest.emoji} 学完${interest.label},${TRAITS[interest.trait].label} +${interest.gain}`, nowMs)
193
+ announce(state, 'study', `${state.name} 学会了${interest.label},${TRAITS[interest.trait].label} +${interest.gain} ${interest.emoji}`, nowMs)
194
+ noteToday(state, 'study')
195
+ if (certified) {
196
+ remember(state, `📜 拿到了${interest.certificate}`, nowMs)
197
+ announce(state, 'certificate', `${state.name} 拿到了${interest.certificate} 📜`, nowMs)
198
+ }
199
+ }
200
+
201
+ export function finishWork(state, activity, nowMs, next = rollerFor(state)) {
202
+ const job = jobByKey(activity.key)
203
+ // A shift started under an older job table is paid what it promised then
204
+ // (the v11 upgrade stamps `legacyCoins` on it), even if the job is gone.
205
+ if (job === null && !Number.isFinite(activity.legacyCoins)) return
206
+ const base = job === null ? activity.legacyCoins : job.coins
207
+ const trait = job === null ? null : job.trait
208
+ // A sick pig still goes to work — that is the way out of the sick-and-broke
209
+ // deadlock — but at half pay, so being ill costs money rather than being a wall.
210
+ const sick = state.illness !== null
211
+ const points = trait === null ? 0 : (state.traits?.[trait] ?? 0)
212
+ const withTrait = trait === null ? base : base * traitBonus(trait, points).pay
213
+ const coins = sick ? Math.max(1, Math.round(withTrait * SICK_PAY_MULTIPLIER)) : Math.round(withTrait)
214
+ state.coins += coins
215
+ if (job !== null) {
216
+ state.satiety = clamp100(state.satiety + job.satiety)
217
+ state.cleanliness = clamp100(state.cleanliness + job.cleanliness)
218
+ }
219
+ state.stats.jobs += 1
220
+ state.stats.coinsEarned += coins
221
+ noteToday(state, 'work')
222
+ noteToday(state, 'coinsEarned', coins)
223
+ noteOuting(state)
224
+ grow(state, outingGrowth(job === null ? activity.minutes ?? 0 : job.minutes), nowMs)
225
+ const brought = workDrops(state, coins, next)
226
+ const label = job === null ? activity.label : job.label
227
+ const emoji = job === null ? activity.emoji : job.emoji
228
+ const tag = sick ? '(带病上工,只有一半)' : (points > 0 && trait !== null ? `(${TRAITS[trait].label} ${points})` : '')
229
+ const extra = brought.length > 0 ? `,还带回了${describeDrops(brought)}` : ''
230
+ remember(state, `${emoji} ${label}回来,赚了 ${coins} 金币${tag}${extra}`, nowMs)
231
+ announce(state, 'work', sick
232
+ ? `${state.name} 带病打工回来了,只赚到 ${coins} 金币 🤒${extra}`
233
+ : `${state.name} 打工回来了!赚到 ${coins} 金币 💰${extra}`, nowMs)
234
+ say(state, (state.outingStreak ?? 0) >= ILLNESS_ONSET.overworkStreak ? 'tired' : 'workDone', nowMs)
235
+ }
236
+
237
+ /**
238
+ * A lesson is over: one more on this subject's count. Crossing 9 / 20 / 40 / 95
239
+ * is a graduation — three gifts, extra growth, and 「我没有留级」.
240
+ */
241
+ export function finishStudy(state, activity, nowMs, next = rollerFor(state)) {
242
+ const subject = subjectByKey(activity.key)
243
+ if (subject === null) return
244
+ const taken = state.lessons?.[subject.key] ?? 0
245
+ // The stage is the one the lesson was booked at, so a table change mid-lesson
246
+ // cannot change what it pays.
247
+ const stage = schoolStageByKey(activity.stage) ?? stageForNextLesson(taken)
248
+ state.lessons = { ...(state.lessons ?? {}), [subject.key]: taken + 1 }
249
+ state.traits = { ...(state.traits ?? {}) }
250
+ state.traits[subject.trait] = (state.traits[subject.trait] ?? 0) + stage.gain
251
+ if (subject.secondary !== null && stage.secondaryGain > 0) {
252
+ state.traits[subject.secondary] = (state.traits[subject.secondary] ?? 0) + stage.secondaryGain
253
+ }
254
+ state.satiety = clamp100(state.satiety + stage.satiety)
255
+ state.happiness = clamp100(state.happiness + stage.happiness)
256
+ state.stats.courses += 1
257
+ state.stats.lessons += 1
258
+ noteOuting(state)
259
+ grow(state, STUDY_GROWTH_PER_LESSON, nowMs)
260
+ const gains = `${TRAITS[subject.trait].label} +${stage.gain}`
261
+ + (subject.secondary !== null && stage.secondaryGain > 0 ? `、${TRAITS[subject.secondary].label} +${stage.secondaryGain}` : '')
262
+ if (GRADUATION_LESSONS.includes(taken + 1)) {
263
+ const gifts = graduationDrops(state, next)
264
+ grow(state, GRADUATION_GROWTH, nowMs)
265
+ state.stats.graduations = (state.stats.graduations ?? 0) + 1
266
+ remember(state, `🎓 ${subject.label}${stage.label}毕业(第 ${taken + 1} 节)`, nowMs)
267
+ announce(state, 'graduate', `${state.name} ${subject.label}${stage.label}毕业啦 🎓 ${gains},带回${describeDrops(gifts)}`, nowMs)
268
+ noteToday(state, 'graduate')
269
+ say(state, 'graduate', nowMs)
270
+ return
271
+ }
272
+ const brought = studyDrops(state, next)
273
+ const extra = brought.length > 0 ? `,还带回了${describeDrops(brought)}` : ''
274
+ remember(state, `${subject.emoji} 上完${subject.label}第 ${taken + 1} 节,${gains}`, nowMs)
275
+ announce(state, 'study', `${state.name} 上完${subject.label}第 ${taken + 1} 节,${gains} 📚${extra}`, nowMs)
276
+ noteToday(state, 'study')
277
+ say(state, (state.outingStreak ?? 0) >= ILLNESS_ONSET.overworkStreak ? 'tired' : 'study', nowMs)
278
+ }
279
+
280
+ export function finishTrip(state, activity, nowMs) {
281
+ const trip = tripByKey(activity.key)
282
+ if (trip === null) return
283
+ // Deterministic souvenir rotation keeps the mechanic testable without RNG —
284
+ // and the rarity is a property of the souvenir, so "far trips are worth more"
285
+ // is a fact about the table rather than a dice roll.
286
+ const pick = trip.souvenirs[state.stats.trips % trip.souvenirs.length]
287
+ const tier = rarityByKey(pick.rarity)
288
+ state.souvenirs = [...(state.souvenirs ?? []), {
289
+ key: pick.key, emoji: pick.emoji, label: pick.label,
290
+ rarity: pick.rarity, story: pick.story,
291
+ from: trip.key, fromLabel: trip.label,
292
+ }]
293
+ state.happiness = clamp100(state.happiness + trip.happiness)
294
+ state.satiety = clamp100(state.satiety + trip.satiety)
295
+ state.stats.trips += 1
296
+ grow(state, outingGrowth(trip.minutes), nowMs)
297
+ remember(state, `${trip.emoji} ${trip.label}回来,带回「${pick.label}」${tier.emoji}`, nowMs)
298
+ announce(state, 'trip', `${state.name} 从${trip.label}回来了,带回「${pick.label}」${tier.emoji}🧳`, nowMs)
299
+ noteToday(state, 'trip')
300
+ noteToday(state, 'souvenirs')
301
+ say(state, 'tripBack', nowMs)
302
+ }
303
+
304
+ export { advanceIllness, catchIllness } from './illness.js'
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Effects and level crossings
308
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+ /**
3
+ * 状态迁移(重置/继承/领养/改名/复活)。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/state
7
+ */
8
+
9
+ import { DEFAULT_TIME_SCALE, MAX, MAX_LEVEL, REVIVE_ITEM, xpForLevel } from '../data.js'
10
+ import { lifeStageFor } from './clock.js'
11
+ import { MEMORY_LIMIT } from './constants.js'
12
+ import { announce, clamp, remember } from './effects.js'
13
+ import { layEgg } from './egg.js'
14
+ import { say } from './lines.js'
15
+ import { sanitizeIllness, sanitizeInventory, sanitizeTraits } from './migrate.js'
16
+ import { decay, die } from './settlement.js'
17
+
18
+ /**
19
+ * Wipe the pig and start from a fresh box, whatever state it was in.
20
+ *
21
+ * `adopt` only works once a pig has died, which meant there was no way to start
22
+ * over with a living one short of deleting the save file by hand — and the
23
+ * running host holds the state in memory, so editing the file does not even
24
+ * work while it is up.
25
+ */
26
+ export function reset(nowMs) {
27
+ return layEgg(nowMs)
28
+ }
29
+
30
+ /**
31
+ * What a new pig inherits from the old one.
32
+ *
33
+ * Schooling, traits and souvenirs carry over. Growth does not: since B2 the
34
+ * level *is* the body, and a new piglet that inherited Lv40 would hatch fully
35
+ * grown. Losing a pig is only ever an accident now (there is no old age), and
36
+ * the revive item is the way to keep the one you have.
37
+ */
38
+ export const INHERITED = ['traits', 'lessons', 'interests', 'souvenirs']
39
+
40
+ export function inherit(oldState, fresh, nowMs) {
41
+ if (oldState === null) return fresh
42
+ for (const key of INHERITED) {
43
+ if (oldState[key] !== undefined) fresh[key] = structuredCloneish(oldState[key])
44
+ }
45
+ if (Array.isArray(oldState.memories)) fresh.memories = oldState.memories.slice(-MEMORY_LIMIT)
46
+ remember(fresh, '🐖 新的小猪来了,本事和收藏都留下了', nowMs)
47
+ return fresh
48
+ }
49
+
50
+ /** JSON round-trip; every inherited field is plain data. */
51
+ export function structuredCloneish(value) {
52
+ try { return JSON.parse(JSON.stringify(value)) } catch (error) { return value }
53
+ }
54
+
55
+ /** Change how fast pig time runs. Only affects the future, never the past. */
56
+ export function setTimeScale(state, scale, nowMs) {
57
+ if (state === null) return state
58
+ const value = Number.isFinite(scale) && scale > 0 ? Math.min(365, scale) : DEFAULT_TIME_SCALE
59
+ state.timeScale = value
60
+ remember(state, `⏱ 时间倍率改成 ×${value}`, nowMs)
61
+ return state
62
+ }
63
+
64
+ /** Put the pig's clock back to now, so its age counts real time again. */
65
+ export function ageFromNow(state, nowMs) {
66
+ if (state === null) return state
67
+ state.bornAt = nowMs
68
+ state.ageMs = 0
69
+ state.ageForced = false
70
+ state.stage = lifeStageFor(state, nowMs).key
71
+ state.lastSeenAt = nowMs
72
+ remember(state, '🔧 年龄归零,从现在开始按真实时间算', nowMs)
73
+ return state
74
+ }
75
+
76
+ /**
77
+ * ---------------------------------------------------------------------------
78
+ * Developer mode
79
+ *
80
+ * Applies an arbitrary patch to the pig so the panel can be driven into any
81
+ * state without waiting days for it. Everything is clamped through the same
82
+ * bounds the game uses, so dev mode can produce a *valid* state but never a
83
+ * corrupt one — no negative coins, no health of 99, no dangling illness.
84
+ * ---------------------------------------------------------------------------
85
+ */
86
+
87
+ /** Numeric fields dev mode may set, with their legal range. */
88
+ export const DEV_NUMBERS = Object.freeze({
89
+ satiety: [0, 100],
90
+ happiness: [0, 100],
91
+ cleanliness: [0, 100],
92
+ health: [0, MAX.health],
93
+ coins: [0, 1_000_000],
94
+ xp: [0, 10_000_000],
95
+ weightG: [400, 500_000],
96
+ })
97
+
98
+ export function applyDevPatch(state, patch, nowMs) {
99
+ if (state === null || typeof patch !== 'object' || patch === null) return state
100
+ const before = { dead: state.dead, hatched: state.hatched, stage: state.stage }
101
+
102
+ for (const [key, [lo, hi]] of Object.entries(DEV_NUMBERS)) {
103
+ const value = patch[key]
104
+ if (typeof value === 'number' && Number.isFinite(value)) {
105
+ state[key] = clamp(Math.round(value), lo, hi)
106
+ }
107
+ }
108
+
109
+ if (patch.traits !== null && typeof patch.traits === 'object') {
110
+ state.traits = sanitizeTraits({ ...state.traits, ...patch.traits })
111
+ }
112
+
113
+ if (patch.illness === null) state.illness = null
114
+ else if (typeof patch.illness === 'object' && patch.illness !== null) {
115
+ state.illness = sanitizeIllness({ ...patch.illness, since: nowMs, progressMs: 0 }, nowMs)
116
+ }
117
+
118
+ if (patch.inventory !== null && typeof patch.inventory === 'object') {
119
+ state.inventory = sanitizeInventory({ ...state.inventory, ...patch.inventory })
120
+ }
121
+
122
+ // Fast-forward: decay the pig as if `__advanceMs` had really passed. This is
123
+ // the whole point of dev mode — the interesting states take days to reach.
124
+ if (typeof patch.__advanceMs === 'number' && Number.isFinite(patch.__advanceMs) && patch.__advanceMs > 0) {
125
+ state.lastSeenAt = nowMs - Math.min(patch.__advanceMs, 60 * 86_400_000)
126
+ decay(state, nowMs)
127
+ }
128
+
129
+ // Level is what takes months to see now (B2): jump straight to one.
130
+ if (typeof patch.level === 'number' && Number.isFinite(patch.level)) {
131
+ state.xp = xpForLevel(clamp(Math.round(patch.level), 1, MAX_LEVEL))
132
+ }
133
+
134
+ // Age only counts days on the panel now; still jumpable for testing.
135
+ if (typeof patch.ageDays === 'number' && Number.isFinite(patch.ageDays)) {
136
+ // Age is accumulated pig time, so both have to move or the stage will not.
137
+ const days = Math.max(0, patch.ageDays)
138
+ state.bornAt = nowMs - days * 86_400_000
139
+ state.ageMs = days * 86_400_000
140
+ // Mark it, so a forced age is never mistaken for the pig simply growing up.
141
+ state.ageForced = true
142
+ }
143
+
144
+ // Health 0 is death, whether or not the panel also ticked "dead": leaving a
145
+ // 0-health pig walking around showed a corpse with no grave or announcement.
146
+ if (state.dead !== true && state.health <= 0) {
147
+ die(state, nowMs, '被开发者按死了')
148
+ }
149
+
150
+ if (patch.dead === true) {
151
+ die(state, nowMs, '被开发者按死了')
152
+ } else if (patch.dead === false && state.dead === true) {
153
+ revive(state, nowMs)
154
+ }
155
+
156
+ if (patch.hatched === true && state.hatched !== true) state.hatched = true
157
+ if (patch.hatched === false) {
158
+ state.hatched = false
159
+ state.dead = false
160
+ state.diedAt = null
161
+ state.stage = 'box'
162
+ }
163
+
164
+ if (patch.activity === null) state.activity = null
165
+ if (patch.outingStreak === 0) state.outingStreak = 0
166
+
167
+ state.stage = lifeStageFor(state, nowMs).key
168
+ state.lastSeenAt = nowMs
169
+ remember(state, `🔧 开发者改了状态(${before.stage} → ${state.stage})`, nowMs)
170
+ return state
171
+ }
172
+
173
+ /** Start over with a fresh box. The old pig's story stays in `memories`. */
174
+ export function adopt(state, nowMs) {
175
+ const fresh = inherit(state, layEgg(nowMs), nowMs)
176
+ remember(fresh, '又领养了一只,纸盒里传来窸窸窣窣的声音 📦', nowMs)
177
+ return Object.assign(state ?? {}, fresh)
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Creation and migration
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export function revive(state, nowMs) {
185
+ state.dead = false
186
+ state.diedAt = null
187
+ state.health = MAX.health
188
+ state.satiety = Math.max(state.satiety, 60)
189
+ state.cleanliness = Math.max(state.cleanliness, 60)
190
+ state.happiness = Math.max(state.happiness, 50)
191
+ state.illness = null
192
+ state.activity = null
193
+ state.outingStreak = 0
194
+ state.stats.revives = (state.stats.revives ?? 0) + 1
195
+ remember(state, `被 ${REVIVE_ITEM.label} 救了回来 ✨`, nowMs)
196
+ announce(state, 'revived', `${state.name} 回来了 ✨`, nowMs)
197
+ say(state, 'revive', nowMs)
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Naming and mood
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export function rename(state, rawName, nowMs) {
205
+ const cleaned = String(rawName ?? '').replace(/\s+/g, ' ').trim()
206
+ if (cleaned === '' || [...cleaned].length > 16) return null
207
+ state.name = cleaned
208
+ remember(state, `改名叫「${cleaned}」`, nowMs)
209
+ return cleaned
210
+ }
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+ /**
3
+ * 旅行与纪念品。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/travel
7
+ */
8
+
9
+ import { rarityByKey, tripByKey } from '../data.js'
10
+ import { begin } from './activity.js'
11
+ import { TOO_WEAK_HEALTH } from './constants.js'
12
+ import { remember } from './effects.js'
13
+
14
+ /**
15
+ * Sell one souvenir from the collection.
16
+ *
17
+ * Souvenirs used to be strings with no way out of the list; now each one has a
18
+ * rarity and a price, so the collection is a wallet as well as a shelf.
19
+ *
20
+ * @returns `{ok: true, sold, coins}` or `{ok: false, reason}`.
21
+ */
22
+ export function sellSouvenir(state, souvenirKey, nowMs) {
23
+ if (state === null || state === undefined) return { ok: false, reason: 'absent' }
24
+ if (state.dead === true) return { ok: false, reason: 'dead' }
25
+ const list = Array.isArray(state.souvenirs) ? state.souvenirs : []
26
+ const index = list.findIndex(entry => entry !== null && typeof entry === 'object' && entry.key === souvenirKey)
27
+ if (index < 0) return { ok: false, reason: 'not-owned' }
28
+ const entry = list[index]
29
+ const tier = rarityByKey(entry.rarity)
30
+ state.souvenirs = [...list.slice(0, index), ...list.slice(index + 1)]
31
+ state.coins += tier.price
32
+ state.stats.sales = (state.stats.sales ?? 0) + 1
33
+ remember(state, `把「${entry.label}」卖了 ${tier.price} 金币`, nowMs)
34
+ return { ok: true, sold: entry.key, coins: tier.price, rarity: tier.key }
35
+ }
36
+
37
+ export function startTrip(state, tripKey, nowMs) {
38
+ if (state.hatched !== true) return { ok: false, reason: 'box' }
39
+ const trip = tripByKey(tripKey)
40
+ if (trip === null) return { ok: false, reason: 'unknown' }
41
+ if (state.dead) return { ok: false, reason: 'dead' }
42
+ if (state.activity !== null) return { ok: false, reason: 'away' }
43
+ if (state.health <= TOO_WEAK_HEALTH) return { ok: false, reason: 'weak' }
44
+ if (state.coins < trip.cost) return { ok: false, reason: 'poor', price: trip.cost }
45
+ if (state.satiety < 15) return { ok: false, reason: 'hungry' }
46
+
47
+ state.coins -= trip.cost
48
+ const result = begin(state, {
49
+ kind: 'trip', key: trip.key, label: trip.label, emoji: trip.emoji,
50
+ minutes: trip.minutes, cost: trip.cost,
51
+ }, nowMs)
52
+ if (!result.ok) {
53
+ state.coins += trip.cost
54
+ return result
55
+ }
56
+ remember(state, `${trip.emoji} 出发去${trip.label}(花了 ${trip.cost} 金币)`, nowMs)
57
+ return result
58
+ }