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.
- package/CHANGELOG.md +1348 -0
- package/LICENSE +32 -0
- package/README.md +322 -0
- package/THIRD-PARTY.md +31 -0
- package/assets/elder.svg +35 -0
- package/assets/pig-king-bathe.svg +38 -0
- package/assets/pig-king-eat.svg +45 -0
- package/assets/pig-king-pet.svg +45 -0
- package/assets/pig-king-play.svg +45 -0
- package/assets/pig-king-relaxed.svg +56 -0
- package/assets/pig-king-study.svg +45 -0
- package/assets/pig-king-trip.svg +45 -0
- package/assets/pig-king-work.svg +45 -0
- package/assets/pig-king.svg +56 -0
- package/assets/piglet.svg +30 -0
- package/client.js +3438 -0
- package/commands.js +252 -0
- package/cordis.patch.yml +15 -0
- package/core.js +11 -0
- package/data.js +10 -0
- package/index.js +68 -0
- package/package.json +72 -0
- package/packages/pet-core/package.json +21 -0
- package/packages/pet-core/src/core/activity.js +80 -0
- package/packages/pet-core/src/core/care.js +160 -0
- package/packages/pet-core/src/core/clock.js +122 -0
- package/packages/pet-core/src/core/constants.js +109 -0
- package/packages/pet-core/src/core/daily.js +204 -0
- package/packages/pet-core/src/core/diary.js +108 -0
- package/packages/pet-core/src/core/drops.js +82 -0
- package/packages/pet-core/src/core/effects.js +84 -0
- package/packages/pet-core/src/core/egg.js +120 -0
- package/packages/pet-core/src/core/evolution.js +90 -0
- package/packages/pet-core/src/core/growth.js +110 -0
- package/packages/pet-core/src/core/illness.js +235 -0
- package/packages/pet-core/src/core/interests.js +55 -0
- package/packages/pet-core/src/core/inventory.js +177 -0
- package/packages/pet-core/src/core/lines.js +157 -0
- package/packages/pet-core/src/core/migrate.js +226 -0
- package/packages/pet-core/src/core/profile.js +102 -0
- package/packages/pet-core/src/core/random.js +79 -0
- package/packages/pet-core/src/core/school.js +81 -0
- package/packages/pet-core/src/core/settlement.js +308 -0
- package/packages/pet-core/src/core/state.js +210 -0
- package/packages/pet-core/src/core/travel.js +58 -0
- package/packages/pet-core/src/core/upgrades.js +193 -0
- package/packages/pet-core/src/core/views.js +55 -0
- package/packages/pet-core/src/core/work.js +50 -0
- package/packages/pet-core/src/core.js +95 -0
- package/packages/pet-core/src/data/daily.js +121 -0
- package/packages/pet-core/src/data/drops.js +34 -0
- package/packages/pet-core/src/data/evolution.js +41 -0
- package/packages/pet-core/src/data/growth.js +72 -0
- package/packages/pet-core/src/data/illness.js +188 -0
- package/packages/pet-core/src/data/interests.js +39 -0
- package/packages/pet-core/src/data/jobs.js +150 -0
- package/packages/pet-core/src/data/life.js +88 -0
- package/packages/pet-core/src/data/lines.js +198 -0
- package/packages/pet-core/src/data/minutes.js +23 -0
- package/packages/pet-core/src/data/profile.js +60 -0
- package/packages/pet-core/src/data/school.js +99 -0
- package/packages/pet-core/src/data/shop.js +142 -0
- package/packages/pet-core/src/data/traits.js +42 -0
- package/packages/pet-core/src/data/travel.js +87 -0
- package/packages/pet-core/src/data.js +52 -0
- package/packages/pet-core/src/index.js +12 -0
- package/render.js +357 -0
- package/routes.js +201 -0
- package/scripts/build-client.mjs +100 -0
- package/scripts/typecheck.mjs +122 -0
- package/snapshot.js +368 -0
- package/src/client/art.js +29 -0
- package/src/client/constants.js +88 -0
- package/src/client/css-base.js +225 -0
- package/src/client/css-card.js +63 -0
- package/src/client/css-tabs.js +250 -0
- package/src/client/css-tiles.js +89 -0
- package/src/client/dom.js +33 -0
- package/src/client/effects.js +159 -0
- package/src/client/format.js +27 -0
- package/src/client/index.js +393 -0
- package/src/client/io.js +100 -0
- package/src/client/layout.js +103 -0
- package/src/client/normalize.js +352 -0
- package/src/client/panel.js +319 -0
- package/src/client/scene.js +113 -0
- package/src/client/storage.js +19 -0
- package/src/client/styles.js +13 -0
- package/src/client/tabs/bag.js +203 -0
- package/src/client/tabs/card.js +110 -0
- package/src/client/tabs/crown.js +59 -0
- package/src/client/tabs/dev.js +101 -0
- package/src/client/tabs/home.js +72 -0
- package/src/client/tabs/shop.js +78 -0
- package/src/client/tabs/status.js +223 -0
- package/src/client/tabs/study.js +128 -0
- package/src/client/tabs/travel.js +89 -0
- package/src/client/tabs/update.js +164 -0
- package/src/client/tabs/work.js +106 -0
- package/src/client/values.js +11 -0
- package/src/client/widgets.js +126 -0
- package/store/api.js +260 -0
- package/store/state-file.js +129 -0
- 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
|
+
}
|