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,235 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 生病与治病:按条件概率发病、病情推进与自愈、吃药(对症 / 吃错 / 百草丹)、看医生、死亡。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,随机数来自 core/random.js(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* 数字见 data/illness.js(docs/tasks/numbers/B3-illness.md)。
|
|
7
|
+
* @module dsh-piggy/core/illness
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CHAIN_INDEX,
|
|
12
|
+
DOCTOR_MARKUP,
|
|
13
|
+
GRAVE,
|
|
14
|
+
ILLNESS_CHAINS,
|
|
15
|
+
ILLNESS_ONSET,
|
|
16
|
+
MAX,
|
|
17
|
+
REVIVE_ITEM,
|
|
18
|
+
SELF_HEAL_CHANCE,
|
|
19
|
+
STAGE_HEALTH,
|
|
20
|
+
THRESHOLDS,
|
|
21
|
+
illnessAt,
|
|
22
|
+
nextIllness,
|
|
23
|
+
} from '../data.js'
|
|
24
|
+
import { announce, remember } from './effects.js'
|
|
25
|
+
import { say } from './lines.js'
|
|
26
|
+
import { chance, rollerFor } from './random.js'
|
|
27
|
+
import { noteToday } from './diary.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Let the pig go. Only accidents kill it (the end of an illness chain, or a
|
|
31
|
+
* wrong medicine at the last stage); there is no old age (B2).
|
|
32
|
+
* @param {string} why - shown in the announcement.
|
|
33
|
+
*/
|
|
34
|
+
export function die(state, nowMs, why) {
|
|
35
|
+
if (state.dead === true) return
|
|
36
|
+
state.dead = true
|
|
37
|
+
state.health = 0
|
|
38
|
+
state.illness = null
|
|
39
|
+
state.activity = null
|
|
40
|
+
state.diedAt = nowMs
|
|
41
|
+
state.stats.deaths = (state.stats.deaths ?? 0) + 1
|
|
42
|
+
remember(state, `${why} ${GRAVE.emoji}`, nowMs)
|
|
43
|
+
announce(state, 'death', `${state.name} ${why}…用${REVIVE_ITEM.label}可以救回来,也可以领养一只新的`, nowMs)
|
|
44
|
+
say(state, 'death', nowMs)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function currentIllness(state) {
|
|
48
|
+
if (state.illness === null || state.illness === undefined) return null
|
|
49
|
+
return illnessAt(state.illness.chain, state.illness.stage)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Falling ill
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the pig is at risk of right now, per hour, and from which chain.
|
|
58
|
+
* Each condition adds its own chance and points at its own chain, so the
|
|
59
|
+
* illness tells the owner what went wrong: hungry → a cold, filthy → skin.
|
|
60
|
+
* @param {object} state
|
|
61
|
+
* @returns {Array<{chain: number, perHour: number}>}
|
|
62
|
+
*/
|
|
63
|
+
export function onsetRisks(state) {
|
|
64
|
+
/** @type {Array<{chain: number, perHour: number}>} */
|
|
65
|
+
const risks = [{ chain: -1, perHour: ILLNESS_ONSET.basePerHour }]
|
|
66
|
+
if (state.satiety < THRESHOLDS.hungry) risks.push({ chain: CHAIN_INDEX.cold, perHour: ILLNESS_ONSET.hungryPerHour })
|
|
67
|
+
if (state.cleanliness < THRESHOLDS.dirty) {
|
|
68
|
+
const chain = state.cleanliness < ILLNESS_ONSET.veryDirty ? CHAIN_INDEX.skin : CHAIN_INDEX.cough
|
|
69
|
+
risks.push({ chain, perHour: ILLNESS_ONSET.dirtyPerHour })
|
|
70
|
+
}
|
|
71
|
+
if (state.happiness < ILLNESS_ONSET.sadBelow) risks.push({ chain: CHAIN_INDEX.dizzy, perHour: ILLNESS_ONSET.sadPerHour })
|
|
72
|
+
if ((state.outingStreak ?? 0) >= ILLNESS_ONSET.overworkStreak) {
|
|
73
|
+
risks.push({ chain: CHAIN_INDEX.dizzy, perHour: ILLNESS_ONSET.overworkPerHour })
|
|
74
|
+
}
|
|
75
|
+
return risks
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One stretch of time at home: maybe fall ill. The hourly chances are turned
|
|
80
|
+
* into a chance for this many minutes, so stepping in 5-minute slices gives
|
|
81
|
+
* the same odds as one long step would.
|
|
82
|
+
* @param {object} state
|
|
83
|
+
* @param {{minutes: number, atMs: number}} span
|
|
84
|
+
* @param {import('./random.js').Roll} next
|
|
85
|
+
*/
|
|
86
|
+
export function rollForIllness(state, span, next) {
|
|
87
|
+
if (state.illness !== null && state.illness !== undefined) return
|
|
88
|
+
const risks = onsetRisks(state)
|
|
89
|
+
const perHour = risks.reduce((sum, risk) => sum + risk.perHour, 0)
|
|
90
|
+
const hit = 1 - Math.pow(1 - Math.min(1, perHour), span.minutes / 60)
|
|
91
|
+
if (!chance(next, hit)) return
|
|
92
|
+
// Which condition did it: weighted by each one's share of the risk. The
|
|
93
|
+
// base chance (chain -1) picks any chain at random.
|
|
94
|
+
let pick = next() * perHour
|
|
95
|
+
let cause = risks[0]
|
|
96
|
+
for (const risk of risks) {
|
|
97
|
+
cause = risk
|
|
98
|
+
pick -= risk.perHour
|
|
99
|
+
if (pick < 0) break
|
|
100
|
+
}
|
|
101
|
+
const chain = cause.chain >= 0 ? cause.chain : Math.min(ILLNESS_CHAINS.length - 1, Math.floor(next() * ILLNESS_CHAINS.length))
|
|
102
|
+
catchIllness(state, chain, span.atMs)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Feeding a pig that is already stuffed can upset its stomach. */
|
|
106
|
+
export function rollForOverfeeding(state, satietyBefore, nowMs) {
|
|
107
|
+
if (satietyBefore < ILLNESS_ONSET.overfullAt || state.illness !== null) return
|
|
108
|
+
if (chance(rollerFor(state), ILLNESS_ONSET.overfeedChance)) catchIllness(state, CHAIN_INDEX.stomach, nowMs)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Work, school and trips in a row wear the pig out; an hour at home resets it. */
|
|
112
|
+
export function noteOuting(state) {
|
|
113
|
+
state.outingStreak = (state.outingStreak ?? 0) + 1
|
|
114
|
+
state.restMinutes = 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function restAtHome(state, minutes) {
|
|
118
|
+
state.restMinutes = (state.restMinutes ?? 0) + minutes
|
|
119
|
+
if (state.restMinutes >= ILLNESS_ONSET.restMinutes) state.outingStreak = 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} state
|
|
124
|
+
* @param {number} chain - index into ILLNESS_CHAINS
|
|
125
|
+
* @param {number} nowMs
|
|
126
|
+
*/
|
|
127
|
+
export function catchIllness(state, chain, nowMs) {
|
|
128
|
+
state.illness = { chain, stage: 1, since: nowMs, progressMs: 0 }
|
|
129
|
+
state.health = STAGE_HEALTH[0]
|
|
130
|
+
state.stats.illnesses = (state.stats.illnesses ?? 0) + 1
|
|
131
|
+
const ill = illnessAt(chain, 1)
|
|
132
|
+
if (ill === null) return
|
|
133
|
+
remember(state, `得了${ill.name} 🤒`, nowMs)
|
|
134
|
+
announce(state, 'sick', `${state.name} 得了${ill.name},需要${ill.cureEmoji}${ill.cure} 🤒`, nowMs)
|
|
135
|
+
noteToday(state, 'illness')
|
|
136
|
+
say(state, 'sick', nowMs)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Getting worse, getting better
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A stage ran its course: it may shake itself off, otherwise it gets worse.
|
|
145
|
+
* @param {object} state
|
|
146
|
+
* @param {number} nowMs
|
|
147
|
+
* @param {import('./random.js').Roll} [next] - defaults to the pig's own seed.
|
|
148
|
+
*/
|
|
149
|
+
export function advanceIllness(state, nowMs, next = rollerFor(state)) {
|
|
150
|
+
const healChance = SELF_HEAL_CHANCE[state.illness.stage - 1] ?? 0
|
|
151
|
+
if (chance(next, healChance)) {
|
|
152
|
+
const name = ILLNESS_CHAINS[state.illness.chain].name
|
|
153
|
+
recover(state, nowMs)
|
|
154
|
+
remember(state, `自己好了,扛过去了 💚`, nowMs)
|
|
155
|
+
announce(state, 'cured', `${state.name} 的${name}自己好了 💚`, nowMs)
|
|
156
|
+
noteToday(state, 'cure')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
worsen(state, nowMs, '没能撑过去')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One stage worse, now. The end of the chain is death.
|
|
164
|
+
* @param {string} why - the cause of death, if it comes to that.
|
|
165
|
+
*/
|
|
166
|
+
export function worsen(state, nowMs, why) {
|
|
167
|
+
const { chain, stage } = state.illness
|
|
168
|
+
const worse = nextIllness(chain, stage)
|
|
169
|
+
if (worse === null) {
|
|
170
|
+
die(state, nowMs, why)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
state.illness = { chain, stage: stage + 1, since: nowMs, progressMs: 0 }
|
|
174
|
+
state.health = STAGE_HEALTH[stage]
|
|
175
|
+
remember(state, `病情加重:${worse.name}`, nowMs)
|
|
176
|
+
announce(state, 'worse', `${state.name} 的病情加重了:${worse.name},需要${worse.cureEmoji}${worse.cure}`, nowMs)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Well again, all the way (#7): an illness leaves no permanent dent. */
|
|
180
|
+
function recover(state, nowMs) {
|
|
181
|
+
state.illness = null
|
|
182
|
+
state.health = MAX.health
|
|
183
|
+
say(state, 'cured', nowMs)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Treatment
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Give the pig a medicine it already has in the bag (the caller spends it).
|
|
192
|
+
* The right cure, or 百草丹, makes it well. The wrong one makes it worse at
|
|
193
|
+
* once, as in the original — which is why the panel always says what it needs.
|
|
194
|
+
* @returns {{ok: true, cured: true}|{ok: false, reason: 'not-sick'}|{ok: false, reason: 'wrong-medicine', needs: object|null}}
|
|
195
|
+
*/
|
|
196
|
+
export function medicate(state, item, nowMs) {
|
|
197
|
+
const ill = currentIllness(state)
|
|
198
|
+
if (ill === null) return { ok: false, reason: 'not-sick' }
|
|
199
|
+
if (item.cureAll === true || item.key === ill.cureKey) {
|
|
200
|
+
recover(state, nowMs)
|
|
201
|
+
state.stats.cures = (state.stats.cures ?? 0) + 1
|
|
202
|
+
remember(state, `吃了 ${item.emoji} ${item.label},病好了`, nowMs)
|
|
203
|
+
announce(state, 'cured', `${state.name} 吃了 ${item.label},痊愈了 💚`, nowMs)
|
|
204
|
+
noteToday(state, 'cure')
|
|
205
|
+
return { ok: true, cured: true }
|
|
206
|
+
}
|
|
207
|
+
remember(state, `吃错了药(${item.label}),病情加重`, nowMs)
|
|
208
|
+
say(state, 'wrongMedicine', nowMs)
|
|
209
|
+
noteToday(state, 'wrongMedicine')
|
|
210
|
+
worsen(state, nowMs, '吃错了药,没能撑过去')
|
|
211
|
+
return { ok: false, reason: 'wrong-medicine', needs: ill }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What seeing the doctor costs for the illness the pig has now. */
|
|
215
|
+
export function doctorFee(state) {
|
|
216
|
+
const ill = currentIllness(state)
|
|
217
|
+
return ill === null ? null : Math.ceil(ill.curePrice * DOCTOR_MARKUP)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 看医生:pay the fee, skip the shopping, get well. */
|
|
221
|
+
export function seeDoctor(state, nowMs) {
|
|
222
|
+
if (state === null) return { ok: false, reason: 'absent' }
|
|
223
|
+
if (state.dead === true) return { ok: false, reason: 'dead' }
|
|
224
|
+
const fee = doctorFee(state)
|
|
225
|
+
if (fee === null) return { ok: false, reason: 'not-sick' }
|
|
226
|
+
if (state.coins < fee) return { ok: false, reason: 'poor', need: fee, have: state.coins }
|
|
227
|
+
state.coins -= fee
|
|
228
|
+
recover(state, nowMs)
|
|
229
|
+
state.stats.cures = (state.stats.cures ?? 0) + 1
|
|
230
|
+
state.stats.doctorVisits = (state.stats.doctorVisits ?? 0) + 1
|
|
231
|
+
remember(state, `🏥 看了医生,花了 ${fee} 金币,病好了`, nowMs)
|
|
232
|
+
announce(state, 'cured', `${state.name} 看了医生,痊愈了 💚(${fee} 🪙)`, nowMs)
|
|
233
|
+
noteToday(state, 'cure')
|
|
234
|
+
return { ok: true, fee }
|
|
235
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 兴趣课。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/interests
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { INTERESTS, interestByKey } 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
|
+
/**
|
|
16
|
+
* 兴趣课的修读次数, per interest key.
|
|
17
|
+
*
|
|
18
|
+
* The point of 兴趣 is that it feeds an *existing* trait (智力/魅力/武力) — it
|
|
19
|
+
* is not a fourth axis, so there is nothing else to track.
|
|
20
|
+
*/
|
|
21
|
+
export function interestView(state) {
|
|
22
|
+
const out = {}
|
|
23
|
+
for (const entry of INTERESTS) out[entry.key] = state.interests?.[entry.key] ?? 0
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 兴趣课 — like 上课, but outside the school ladder and repeatable.
|
|
29
|
+
*
|
|
30
|
+
* It pays into the same three traits (智力/魅力/武力); there is no separate
|
|
31
|
+
* skill stat, because a skill stat would just be a second name for those three.
|
|
32
|
+
*/
|
|
33
|
+
export function startInterest(state, interestKey, nowMs) {
|
|
34
|
+
const interest = interestByKey(interestKey)
|
|
35
|
+
if (interest === null) return { ok: false, reason: 'unknown' }
|
|
36
|
+
if (state.hatched !== true) return { ok: false, reason: 'box' }
|
|
37
|
+
if (state.dead) return { ok: false, reason: 'dead' }
|
|
38
|
+
if (state.activity !== null) return { ok: false, reason: 'away' }
|
|
39
|
+
if (state.health <= TOO_WEAK_HEALTH) return { ok: false, reason: 'weak' }
|
|
40
|
+
if (state.coins < interest.cost) return { ok: false, reason: 'poor', price: interest.cost }
|
|
41
|
+
if (state.satiety < 15) return { ok: false, reason: 'hungry' }
|
|
42
|
+
|
|
43
|
+
state.coins -= interest.cost
|
|
44
|
+
const result = begin(state, {
|
|
45
|
+
kind: 'interest', key: interest.key,
|
|
46
|
+
label: `兴趣·${interest.label}`, emoji: interest.emoji,
|
|
47
|
+
minutes: interest.minutes, cost: interest.cost,
|
|
48
|
+
}, nowMs)
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
state.coins += interest.cost
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
remember(state, `${interest.emoji} 去学${interest.label}(花了 ${interest.cost} 金币)`, nowMs)
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 背包、购买、装扮与道具使用。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/inventory
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_TOY, REVIVE_ITEM, SHOP, dressSlotByKey, itemByKey } from '../data.js'
|
|
10
|
+
import { levelProgress } from './clock.js'
|
|
11
|
+
import { applyEffects, remember } from './effects.js'
|
|
12
|
+
import { medicate } from './illness.js'
|
|
13
|
+
import { decay } from './settlement.js'
|
|
14
|
+
import { revive } from './state.js'
|
|
15
|
+
|
|
16
|
+
/** Inventory counts, always including zeroes so the UI can render a grid. */
|
|
17
|
+
export function inventoryView(state) {
|
|
18
|
+
const out = {}
|
|
19
|
+
for (const item of SHOP) {
|
|
20
|
+
// 家当 is not carried in counts — it is owned and worn.
|
|
21
|
+
if (item.kind === 'dress') continue
|
|
22
|
+
out[item.key] = state.inventory?.[item.key] ?? 0
|
|
23
|
+
}
|
|
24
|
+
// The free default toy is always in the bag and never runs out, so `玩耍` is
|
|
25
|
+
// never blocked by an empty one.
|
|
26
|
+
out[DEFAULT_TOY.key] = Infinity
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Owned / worn state for the 装扮 shelf, plus the level each one needs.
|
|
32
|
+
*
|
|
33
|
+
* The panel shows all twelve even when locked: an item you cannot see is an
|
|
34
|
+
* item you will never save up for.
|
|
35
|
+
*/
|
|
36
|
+
export function dressView(state) {
|
|
37
|
+
const owned = new Set(state.dress ?? [])
|
|
38
|
+
const worn = new Set(state.worn ?? [])
|
|
39
|
+
const have = levelProgress(state.xp).level
|
|
40
|
+
return SHOP.filter(item => item.kind === 'dress').map(item => ({
|
|
41
|
+
key: item.key, label: item.label, emoji: item.emoji, price: item.price,
|
|
42
|
+
level: item.level ?? 1,
|
|
43
|
+
// The anchor this piece goes on; one item per slot at a time.
|
|
44
|
+
slot: item.slot ?? null,
|
|
45
|
+
slotLabel: item.slot === undefined ? '' : (dressSlotByKey(item.slot)?.label ?? item.slot),
|
|
46
|
+
blurb: item.blurb ?? '',
|
|
47
|
+
owned: owned.has(item.key),
|
|
48
|
+
worn: worn.has(item.key),
|
|
49
|
+
unlocked: have >= (item.level ?? 1),
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buy(state, itemKey, nowMs) {
|
|
54
|
+
const item = itemByKey(itemKey)
|
|
55
|
+
if (item === null) return { ok: false, reason: 'unknown' }
|
|
56
|
+
if (state.dead && item.key !== REVIVE_ITEM.key) return { ok: false, reason: 'dead' }
|
|
57
|
+
|
|
58
|
+
// 家当 is a different transaction: own it once, then wear it. No counts.
|
|
59
|
+
if (item.kind === 'dress') {
|
|
60
|
+
if ((state.dress ?? []).includes(item.key)) return { ok: false, reason: 'owned', item }
|
|
61
|
+
const have = levelProgress(state.xp).level
|
|
62
|
+
const need = item.level ?? 1
|
|
63
|
+
if (have < need) return { ok: false, reason: 'low-level', need, have, item }
|
|
64
|
+
if (state.coins < item.price) return { ok: false, reason: 'poor', price: item.price }
|
|
65
|
+
state.coins -= item.price
|
|
66
|
+
state.dress = [...(state.dress ?? []), item.key]
|
|
67
|
+
state.stats.purchases += 1
|
|
68
|
+
remember(state, `买下了 ${item.emoji} ${item.label}(-${item.price} 金币)`, nowMs)
|
|
69
|
+
return { ok: true, item }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state.coins < item.price) return { ok: false, reason: 'poor', price: item.price }
|
|
73
|
+
|
|
74
|
+
state.coins -= item.price
|
|
75
|
+
state.inventory = { ...(state.inventory ?? {}) }
|
|
76
|
+
state.inventory[item.key] = (state.inventory[item.key] ?? 0) + 1
|
|
77
|
+
state.stats.purchases += 1
|
|
78
|
+
remember(state, `买了 ${item.emoji} ${item.label}(-${item.price} 金币)`, nowMs)
|
|
79
|
+
return { ok: true, item }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Shared checks for the two 装扮 actions. */
|
|
83
|
+
export function wearableDress(state, itemKey) {
|
|
84
|
+
const item = itemByKey(itemKey)
|
|
85
|
+
if (item === null || item.kind !== 'dress') return { item: null, reason: 'unknown' }
|
|
86
|
+
if (!(state.dress ?? []).includes(item.key)) return { item: null, reason: 'not-owned' }
|
|
87
|
+
return { item, reason: null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Put a 装扮 on. Only owned dress items; one item per slot, so a new hat
|
|
92
|
+
* automatically takes the old one off.
|
|
93
|
+
* @returns `{ ok: true, item, worn }` or `{ ok: false, reason }`.
|
|
94
|
+
*/
|
|
95
|
+
export function wearItem(state, itemKey, nowMs) {
|
|
96
|
+
const { item, reason } = wearableDress(state, itemKey)
|
|
97
|
+
if (item === null) return { ok: false, reason }
|
|
98
|
+
const worn = new Set(state.worn ?? [])
|
|
99
|
+
for (const key of [...worn]) {
|
|
100
|
+
const other = itemByKey(key)
|
|
101
|
+
if (other !== null && other.slot === item.slot) worn.delete(key)
|
|
102
|
+
}
|
|
103
|
+
worn.add(item.key)
|
|
104
|
+
state.worn = [...worn]
|
|
105
|
+
remember(state, `戴上了 ${item.emoji} ${item.label}`, nowMs)
|
|
106
|
+
return { ok: true, item, worn: state.worn.slice() }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Take a 装扮 off. */
|
|
110
|
+
export function takeOff(state, itemKey, nowMs) {
|
|
111
|
+
const { item, reason } = wearableDress(state, itemKey)
|
|
112
|
+
if (item === null) return { ok: false, reason }
|
|
113
|
+
const worn = new Set(state.worn ?? [])
|
|
114
|
+
worn.delete(item.key)
|
|
115
|
+
state.worn = [...worn]
|
|
116
|
+
remember(state, `摘下了 ${item.emoji} ${item.label}`, nowMs)
|
|
117
|
+
return { ok: true, item, worn: state.worn.slice() }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Debug helper: hand the pig one of everything.
|
|
122
|
+
*
|
|
123
|
+
* 20 of each consumable, every 装扮 owned, and enough coins that price is never
|
|
124
|
+
* the reason a test in the panel fails.
|
|
125
|
+
*/
|
|
126
|
+
export function grantAll(state, nowMs) {
|
|
127
|
+
if (state === null || state === undefined) return { ok: false, reason: 'absent' }
|
|
128
|
+
const inventory = { ...(state.inventory ?? {}) }
|
|
129
|
+
for (const item of SHOP) {
|
|
130
|
+
if (item.kind === 'dress') continue
|
|
131
|
+
inventory[item.key] = Math.max(inventory[item.key] ?? 0, 20)
|
|
132
|
+
}
|
|
133
|
+
state.inventory = inventory
|
|
134
|
+
const dress = SHOP.filter(item => item.kind === 'dress').map(item => item.key)
|
|
135
|
+
state.dress = dress
|
|
136
|
+
state.coins = Math.max(state.coins ?? 0, 99999)
|
|
137
|
+
state.stats.purchases = (state.stats.purchases ?? 0) + dress.length
|
|
138
|
+
remember(state, '🔧 调试:一键拿齐了所有物品', nowMs)
|
|
139
|
+
return { ok: true, granted: { items: SHOP.length - dress.length, dress: dress.length, coins: state.coins } }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const canAfford = (state, itemKey) => {
|
|
143
|
+
const item = itemByKey(itemKey)
|
|
144
|
+
return item !== null && state.coins >= item.price
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function useItem(state, itemKey, nowMs) {
|
|
148
|
+
const item = itemByKey(itemKey)
|
|
149
|
+
if (item === null) return { ok: false, reason: 'unknown' }
|
|
150
|
+
// 家当 is worn, not consumed — 穿上 is a different action.
|
|
151
|
+
if (item.kind === 'dress') return { ok: false, reason: 'not-consumable' }
|
|
152
|
+
const have = state.inventory?.[itemKey] ?? 0
|
|
153
|
+
if (have <= 0) return { ok: false, reason: 'empty' }
|
|
154
|
+
decay(state, nowMs)
|
|
155
|
+
|
|
156
|
+
if (item.key === REVIVE_ITEM.key) {
|
|
157
|
+
if (!state.dead) return { ok: false, reason: 'not-dead' }
|
|
158
|
+
state.inventory[itemKey] = have - 1
|
|
159
|
+
revive(state, nowMs)
|
|
160
|
+
return { ok: true, item }
|
|
161
|
+
}
|
|
162
|
+
if (state.dead) return { ok: false, reason: 'dead' }
|
|
163
|
+
|
|
164
|
+
if (item.kind === 'medicine') {
|
|
165
|
+
if (state.illness === null) return { ok: false, reason: 'not-sick' }
|
|
166
|
+
// Swallowed either way: the wrong medicine is spent too, and makes it worse.
|
|
167
|
+
state.inventory[itemKey] = have - 1
|
|
168
|
+
const result = medicate(state, item, nowMs)
|
|
169
|
+
return result.ok ? { ok: true, item } : result
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (state.activity !== null) return { ok: false, reason: 'away' }
|
|
173
|
+
state.inventory[itemKey] = have - 1
|
|
174
|
+
applyEffects(state, item, nowMs)
|
|
175
|
+
remember(state, `用了 ${item.emoji} ${item.label}`, nowMs)
|
|
176
|
+
return { ok: true, item }
|
|
177
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 台词:按场景挑一句、放进消息队列,处理主人的回复。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/lines
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CATCHPHRASE_CHANCE, DEFAULT_OWNER_NAME, LINES, SERIOUS_SCENES, OWNER_NAME_MAX, OWNER_TOKEN, REPLY_HAPPINESS, THRESHOLDS, WELCOME_BACK_AFTER_MINUTES } from '../data.js'
|
|
10
|
+
import { announce, clamp100 } from './effects.js'
|
|
11
|
+
import { chance, rollerFor } from './random.js'
|
|
12
|
+
|
|
13
|
+
/** The dialogue record every pig carries; older saves get it filled in. */
|
|
14
|
+
export function emptyDialogue() {
|
|
15
|
+
return { ownerName: DEFAULT_OWNER_NAME, lastByScene: {}, open: null, quiet: false, greetedAt: 0 }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pick a line for `scene`, never the same one twice in a row.
|
|
20
|
+
* @param {object} state - `dialogue.lastByScene` is updated in place.
|
|
21
|
+
* @param {string} scene
|
|
22
|
+
* @param {import('./random.js').Roll} [next]
|
|
23
|
+
* @returns {{scene: string, text: string, replies: Array<{label: string, happiness: number}>}|null}
|
|
24
|
+
*/
|
|
25
|
+
export function pickLine(state, scene, next = rollerFor(state)) {
|
|
26
|
+
const pool = LINES[scene]
|
|
27
|
+
if (pool === undefined || pool.length === 0) return null
|
|
28
|
+
const dialogue = ensureDialogue(state)
|
|
29
|
+
const last = dialogue.lastByScene[scene]
|
|
30
|
+
let index = Math.min(pool.length - 1, Math.floor(next() * pool.length))
|
|
31
|
+
if (pool.length > 1 && index === last) index = (index + 1) % pool.length
|
|
32
|
+
dialogue.lastByScene = { ...dialogue.lastByScene, [scene]: index }
|
|
33
|
+
const line = pool[index]
|
|
34
|
+
return {
|
|
35
|
+
scene,
|
|
36
|
+
text: withCatchphrase(state, scene, line.text.split(OWNER_TOKEN).join(dialogue.ownerName), next),
|
|
37
|
+
replies: (line.replies ?? []).map(reply => ({ label: reply.label, happiness: reply.happiness ?? REPLY_HAPPINESS })),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Now and then the pig ends a line with its catchphrase, the way the villagers
|
|
43
|
+
* do (B9): 「好舒服…」 → 「好舒服,呼噜…」. Never in serious moments, never on a
|
|
44
|
+
* stage direction like 「(眯起眼睛)」, and never twice.
|
|
45
|
+
*/
|
|
46
|
+
function withCatchphrase(state, scene, text, next) {
|
|
47
|
+
const phrase = typeof state.catchphrase === 'string' ? state.catchphrase.trim() : ''
|
|
48
|
+
if (phrase === '' || SERIOUS_SCENES.includes(scene) || text.startsWith('(') || text.includes(phrase)) return text
|
|
49
|
+
if (!chance(next, CATCHPHRASE_CHANCE)) return text
|
|
50
|
+
const match = /^(.*?)([。!?!?~~…]*)$/.exec(text)
|
|
51
|
+
const body = match === null ? text : match[1]
|
|
52
|
+
const tail = match === null ? '' : match[2]
|
|
53
|
+
return `${body},${phrase}${tail}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Have the pig say something: a `line` message the panel shows in its bubble,
|
|
58
|
+
* with reply buttons when the line has any. Only the newest line can be
|
|
59
|
+
* answered, so an old bubble cannot be replied to twice for extra mood.
|
|
60
|
+
* @returns {boolean} whether a line was queued.
|
|
61
|
+
*/
|
|
62
|
+
export function say(state, scene, nowMs, next) {
|
|
63
|
+
const line = pickLine(state, scene, next)
|
|
64
|
+
if (line === null) return false
|
|
65
|
+
const message = announce(state, 'line', line.text, nowMs, { scene, replies: line.replies.map(reply => reply.label) })
|
|
66
|
+
const dialogue = ensureDialogue(state)
|
|
67
|
+
dialogue.open = line.replies.length > 0 ? { id: message.id, replies: line.replies } : null
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The owner answers the pig's latest line.
|
|
73
|
+
* @param {object} state
|
|
74
|
+
* @param {number} lineId - the `id` of the `line` message being answered.
|
|
75
|
+
* @param {number} replyIndex
|
|
76
|
+
*/
|
|
77
|
+
export function replyToLine(state, lineId, replyIndex) {
|
|
78
|
+
if (state === null) return { ok: false, reason: 'absent' }
|
|
79
|
+
if (state.dead === true) return { ok: false, reason: 'dead' }
|
|
80
|
+
const dialogue = ensureDialogue(state)
|
|
81
|
+
const open = dialogue.open
|
|
82
|
+
if (open === null || open.id !== lineId) return { ok: false, reason: 'stale-line' }
|
|
83
|
+
const reply = open.replies[replyIndex]
|
|
84
|
+
if (reply === undefined) return { ok: false, reason: 'unknown' }
|
|
85
|
+
dialogue.open = null
|
|
86
|
+
state.happiness = clamp100(state.happiness + reply.happiness)
|
|
87
|
+
return { ok: true, reply: reply.label }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The pig speaks up on its own: `enter` when the owner opens DSH after being
|
|
92
|
+
* away a while, `idle` every so often (the panel decides when). An idle line
|
|
93
|
+
* says what the pig needs first — hungry, dirty, lonely — and only chats when
|
|
94
|
+
* it needs nothing. Never while away, ill, dead, a box, or on 免打扰.
|
|
95
|
+
* @param {object} state
|
|
96
|
+
* @param {'enter'|'idle'} reason
|
|
97
|
+
* @param {number} nowMs
|
|
98
|
+
*/
|
|
99
|
+
export function chat(state, reason, nowMs) {
|
|
100
|
+
if (state === null || state.hatched !== true || state.dead === true) return { ok: false, reason: 'silent' }
|
|
101
|
+
const dialogue = ensureDialogue(state)
|
|
102
|
+
if (dialogue.quiet) return { ok: false, reason: 'silent' }
|
|
103
|
+
if (state.activity !== null || state.illness !== null) return { ok: false, reason: 'silent' }
|
|
104
|
+
if (reason === 'enter') {
|
|
105
|
+
if (nowMs - dialogue.greetedAt < WELCOME_BACK_AFTER_MINUTES * 60_000) return { ok: false, reason: 'silent' }
|
|
106
|
+
dialogue.greetedAt = nowMs
|
|
107
|
+
say(state, 'enter', nowMs)
|
|
108
|
+
return { ok: true, scene: 'enter' }
|
|
109
|
+
}
|
|
110
|
+
const scene = state.satiety < THRESHOLDS.hungry ? 'hungry'
|
|
111
|
+
: state.cleanliness < THRESHOLDS.dirty ? 'dirty'
|
|
112
|
+
: state.happiness < THRESHOLDS.lonely ? 'lonely'
|
|
113
|
+
: 'idle'
|
|
114
|
+
say(state, scene, nowMs)
|
|
115
|
+
return { ok: true, scene }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 免打扰: no chatter and no routine toasts; illness and death still speak. */
|
|
119
|
+
export function setQuiet(state, on) {
|
|
120
|
+
if (state === null) return { ok: false, reason: 'absent' }
|
|
121
|
+
ensureDialogue(state).quiet = on === true
|
|
122
|
+
return { ok: true, quiet: on === true }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What the pig calls its owner (the `[主人]` in its lines). */
|
|
126
|
+
export function setOwnerName(state, rawName) {
|
|
127
|
+
if (state === null) return { ok: false, reason: 'absent' }
|
|
128
|
+
const name = typeof rawName === 'string' ? rawName.trim().slice(0, OWNER_NAME_MAX) : ''
|
|
129
|
+
if (name === '') return { ok: false, reason: 'empty' }
|
|
130
|
+
ensureDialogue(state).ownerName = name
|
|
131
|
+
return { ok: true, ownerName: name }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Make sure `state.dialogue` has every field, whatever the save held. */
|
|
135
|
+
export function ensureDialogue(state) {
|
|
136
|
+
const raw = state.dialogue
|
|
137
|
+
const base = emptyDialogue()
|
|
138
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
139
|
+
state.dialogue = base
|
|
140
|
+
return base
|
|
141
|
+
}
|
|
142
|
+
const ownerName = typeof raw.ownerName === 'string' && raw.ownerName.trim() !== '' ? raw.ownerName.trim().slice(0, OWNER_NAME_MAX) : base.ownerName
|
|
143
|
+
const lastByScene = raw.lastByScene !== null && typeof raw.lastByScene === 'object' && !Array.isArray(raw.lastByScene) ? raw.lastByScene : {}
|
|
144
|
+
const open = sanitizeOpenLine(raw.open)
|
|
145
|
+
const quiet = raw.quiet === true
|
|
146
|
+
const greetedAt = Number.isFinite(raw.greetedAt) ? raw.greetedAt : 0
|
|
147
|
+
state.dialogue = { ownerName, lastByScene, open, quiet, greetedAt }
|
|
148
|
+
return state.dialogue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sanitizeOpenLine(raw) {
|
|
152
|
+
if (raw === null || typeof raw !== 'object' || !Number.isInteger(raw.id) || !Array.isArray(raw.replies)) return null
|
|
153
|
+
const replies = raw.replies
|
|
154
|
+
.filter(reply => reply !== null && typeof reply === 'object' && typeof reply.label === 'string')
|
|
155
|
+
.map(reply => ({ label: reply.label, happiness: Number.isFinite(reply.happiness) ? reply.happiness : REPLY_HAPPINESS }))
|
|
156
|
+
return replies.length > 0 ? { id: raw.id, replies } : null
|
|
157
|
+
}
|