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,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
+ }