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,108 @@
1
+ // @ts-check
2
+ /**
3
+ * 宠物日记:把「今天发生了什么」攒起来,跨天(06:00)写成一篇。
4
+ *
5
+ * 零 token:模板拼句,不调模型(data/daily.js 的 DIARY_LINES)。事件点只是在
6
+ * 原有逻辑旁边加一句 `noteToday(state, 'feed')`,判断和写入都在这里。
7
+ *
8
+ * @module dsh-piggy/core/diary
9
+ */
10
+ import { DIARY_EMPTY_LINE, DIARY_LINES, DIARY_MAX, DIARY_MAX_SENTENCES, OWNER_TOKEN } from '../data.js'
11
+ import { dayKeyFor } from './clock.js'
12
+ import { ensureDialogue } from './lines.js'
13
+
14
+ /** 一本空日记:没有历史,今天还没开始记。 */
15
+ export function emptyDiary() {
16
+ return { entries: [], today: { day: null, counts: {} } }
17
+ }
18
+
19
+ /**
20
+ * 补默认值(新字段不加 upgrades 级、不动 STATE_VERSION)。
21
+ * @param {object} state
22
+ */
23
+ export function ensureDiary(state) {
24
+ const raw = state.diary
25
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
26
+ state.diary = emptyDiary()
27
+ return state.diary
28
+ }
29
+ const entries = Array.isArray(raw.entries) ? raw.entries : []
30
+ const kept = []
31
+ for (const entry of entries) {
32
+ if (entry === null || typeof entry !== 'object') continue
33
+ if (typeof entry.day !== 'string' || typeof entry.text !== 'string') continue
34
+ if (entry.day === '' || entry.text === '') continue
35
+ kept.push({ day: entry.day, text: entry.text })
36
+ }
37
+ const today = raw.today !== null && typeof raw.today === 'object' && !Array.isArray(raw.today) ? raw.today : {}
38
+ const counts = {}
39
+ if (today.counts !== null && typeof today.counts === 'object' && !Array.isArray(today.counts)) {
40
+ for (const [key, value] of Object.entries(today.counts)) {
41
+ if (Number.isFinite(value) && value > 0) counts[key] = Math.floor(value)
42
+ }
43
+ }
44
+ state.diary = {
45
+ entries: kept.slice(-DIARY_MAX),
46
+ today: { day: typeof today.day === 'string' && today.day !== '' ? today.day : null, counts },
47
+ }
48
+ return state.diary
49
+ }
50
+
51
+ /**
52
+ * 记一笔今天发生了什么。
53
+ *
54
+ * 时间不进来(事件点只是加一行调用),归属哪一天由 `writeDiaryIfNewDay()` 定:
55
+ * 它每次读状态都会跑,所以计数不会记到错误的日子上。
56
+ * @param {object} state
57
+ * @param {string} kind - DIARY_LINES 里的 key;额外计数(coinsEarned 等)也走这里
58
+ * @param {number} [amount]
59
+ */
60
+ export function noteToday(state, kind, amount = 1) {
61
+ const diary = ensureDiary(state)
62
+ diary.today.counts[kind] = (diary.today.counts[kind] ?? 0) + amount
63
+ return diary
64
+ }
65
+
66
+ /**
67
+ * 把一天的数字拼成一篇日记,最多 5 句。什么都没发生也有话说。
68
+ * @param {Record<string, number>} counts
69
+ * @param {string} ownerName
70
+ */
71
+ export function composeDiary(counts, ownerName) {
72
+ const sentences = []
73
+ for (const entry of DIARY_LINES) {
74
+ if ((counts[entry.key] ?? 0) <= 0) continue
75
+ sentences.push(entry.said(counts).split(OWNER_TOKEN).join(ownerName))
76
+ if (sentences.length >= DIARY_MAX_SENTENCES) break
77
+ }
78
+ if (sentences.length === 0) return DIARY_EMPTY_LINE.split(OWNER_TOKEN).join(ownerName)
79
+ return sentences.join('')
80
+ }
81
+
82
+ /**
83
+ * 跨天了就把昨天(`today`)写成一篇,然后从今天重新开始记。
84
+ *
85
+ * 每次读状态都会调一次:第一次见到今天会把 `today.day` 归位;换了天就落笔。
86
+ * @returns {boolean} 是否写了新的一篇
87
+ */
88
+ export function writeDiaryIfNewDay(state, nowMs) {
89
+ const diary = ensureDiary(state)
90
+ const today = dayKeyFor(nowMs)
91
+ if (diary.today.day === null) {
92
+ diary.today.day = today
93
+ return false
94
+ }
95
+ if (diary.today.day === today) return false
96
+
97
+ const owner = ensureDialogue(state).ownerName
98
+ diary.entries.push({ day: diary.today.day, text: composeDiary(diary.today.counts, owner) })
99
+ if (diary.entries.length > DIARY_MAX) diary.entries.splice(0, diary.entries.length - DIARY_MAX)
100
+ diary.today = { day: today, counts: {} }
101
+ return true
102
+ }
103
+
104
+ /** 面板要的那份:按日期倒序(新的在前)。 */
105
+ export function diaryView(state) {
106
+ const diary = ensureDiary(state)
107
+ return diary.entries.slice().reverse().map(entry => ({ day: entry.day, text: entry.text }))
108
+ }
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ /**
3
+ * 打工、上课回来随手带的东西。
4
+ *
5
+ * 纯函数领域逻辑:随机数来自 core/random.js(见 docs/CONVENTIONS.md)。
6
+ * 数字见 data/drops.js(docs/tasks/numbers/B4-study-jobs.md §4)。
7
+ * @module dsh-piggy/core/drops
8
+ */
9
+
10
+ import {
11
+ DROP_SHELVES,
12
+ GRADUATION_DROP_COUNT,
13
+ GRADUATION_DROP_PRICE,
14
+ SHOP,
15
+ STUDY_DROP_CHANCE,
16
+ STUDY_DROP_MAX_PRICE,
17
+ WORK_DROP,
18
+ WORK_DROP_VALUE_RATIO,
19
+ } from '../data.js'
20
+ import { chance, pickOne } from './random.js'
21
+
22
+ /**
23
+ * One item: pick a shelf by weight, then an item on it within the price range.
24
+ * A shelf with nothing in range falls back to its cheapest item, so a drop is
25
+ * never silently lost.
26
+ * @param {import('./random.js').Roll} next
27
+ * @param {{min?: number, max: number}} price
28
+ * @param {ReadonlyArray<{kinds: ReadonlyArray<string>, weight: number, tier?: number}>} shelves
29
+ */
30
+ function pickItem(next, price, shelves) {
31
+ const total = shelves.reduce((sum, shelf) => sum + shelf.weight, 0)
32
+ let point = next() * total
33
+ let shelf = shelves[0]
34
+ for (const candidate of shelves) {
35
+ shelf = candidate
36
+ point -= candidate.weight
37
+ if (point < 0) break
38
+ }
39
+ const onShelf = SHOP.filter(item =>
40
+ shelf.kinds.includes(item.kind) && item.default !== true && item.cureAll !== true
41
+ && (shelf.tier === undefined || item.tier === shelf.tier))
42
+ const inRange = onShelf.filter(item => item.price <= price.max && item.price >= (price.min ?? 0))
43
+ const pool = inRange.length > 0 ? inRange : onShelf.slice().sort((a, b) => a.price - b.price).slice(0, 1)
44
+ return pickOne(next, pool)
45
+ }
46
+
47
+ /**
48
+ * Put `count` random items in the bag.
49
+ * @returns {Array<{key: string, label: string, emoji: string}>} what was given
50
+ */
51
+ function give(state, count, next, price, shelves = DROP_SHELVES) {
52
+ const given = []
53
+ for (let index = 0; index < count; index += 1) {
54
+ const item = pickItem(next, price, shelves)
55
+ if (item === null) continue
56
+ state.inventory = { ...(state.inventory ?? {}) }
57
+ state.inventory[item.key] = (state.inventory[item.key] ?? 0) + 1
58
+ given.push({ key: item.key, label: item.label, emoji: item.emoji })
59
+ }
60
+ return given
61
+ }
62
+
63
+ /** After a shift: sometimes one thing, now and then two, each worth ≤ 30% of the pay. */
64
+ export function workDrops(state, pay, next) {
65
+ const roll = next()
66
+ const count = roll < WORK_DROP.two ? 2 : roll < WORK_DROP.two + WORK_DROP.one ? 1 : 0
67
+ return give(state, count, next, { max: Math.max(1, pay * WORK_DROP_VALUE_RATIO) })
68
+ }
69
+
70
+ /** After a lesson: now and then one small thing. */
71
+ export function studyDrops(state, next) {
72
+ return chance(next, STUDY_DROP_CHANCE) ? give(state, 1, next, { max: STUDY_DROP_MAX_PRICE }) : []
73
+ }
74
+
75
+ /** A graduation: three things from the better shelves, no medicine. */
76
+ export function graduationDrops(state, next) {
77
+ const shelves = DROP_SHELVES.filter(shelf => !shelf.kinds.includes('medicine'))
78
+ return give(state, GRADUATION_DROP_COUNT, next, GRADUATION_DROP_PRICE, shelves)
79
+ }
80
+
81
+ /** "苹果、香皂" with their emoji — for the announcements. */
82
+ export const describeDrops = given => given.map(item => `${item.emoji}${item.label}`).join('、')
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+ /**
3
+ * 记忆、公告与属性结算。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/effects
7
+ */
8
+
9
+ import { MAX } from '../data.js'
10
+ import { MEMORY_LIMIT, PENDING_LIMIT } from './constants.js'
11
+
12
+ export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
13
+
14
+ export const clamp100 = value => clamp(value, 0, 100)
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Life — the pig is measured in days, not in points
18
+ //
19
+ // QQ Pet's pets hatch, grow up and eventually die; nothing in it is a level to
20
+ // grind. This is that idea, on a clock the pig can actually be watched against.
21
+ // XP still accumulates from real work, but it feeds *weight*: a fatter pig, not
22
+ // a higher one.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export function clock(nowMs) {
26
+ const d = new Date(nowMs)
27
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
28
+ }
29
+
30
+ export function remember(state, text, nowMs) {
31
+ state.memories.push(`[${clock(nowMs)}] ${text}`)
32
+ if (state.memories.length > MEMORY_LIMIT) state.memories.splice(0, state.memories.length - MEMORY_LIMIT)
33
+ }
34
+
35
+ /**
36
+ * Queue a message for the panel.
37
+ *
38
+ * Every message gets an `id` from a counter that only goes up, so the panel
39
+ * can tell two messages from the same instant apart — keying on `at` showed
40
+ * only the first of "病情加重" + "走了" and lost the second.
41
+ * @param {object} state
42
+ * @param {string} kind
43
+ * @param {string} text
44
+ * @param {number} nowMs
45
+ * @param {Record<string, unknown>} [extra] - kind-specific fields, e.g. a line's replies.
46
+ * @returns {{id: number, kind: string, text: string, at: number}}
47
+ */
48
+ export function announce(state, kind, text, nowMs, extra = {}) {
49
+ state.pending = Array.isArray(state.pending) ? state.pending : []
50
+ const id = (Number.isInteger(state.pendingSeq) && state.pendingSeq >= 0 ? state.pendingSeq : 0) + 1
51
+ state.pendingSeq = id
52
+ const message = { ...extra, id, kind, text, at: nowMs }
53
+ state.pending.push(message)
54
+ if (state.pending.length > PENDING_LIMIT) state.pending.splice(0, state.pending.length - PENDING_LIMIT)
55
+ return message
56
+ }
57
+
58
+ export function takePending(state) {
59
+ const out = Array.isArray(state.pending) ? state.pending.slice() : []
60
+ state.pending = []
61
+ return out
62
+ }
63
+
64
+ /** Take and clear the queued announcements. */
65
+ export function drainPending(state) {
66
+ return takePending(state)
67
+ }
68
+
69
+ /**
70
+ * Apply bar, weight and health changes. Growth is not an effect: it goes
71
+ * through growth.js `grow()`, which is what announces level-ups.
72
+ */
73
+ export function applyEffects(state, effects, nowMs) {
74
+ if (effects.satiety) state.satiety = clamp100(state.satiety + effects.satiety)
75
+ if (effects.happiness) state.happiness = clamp100(state.happiness + effects.happiness)
76
+ if (effects.cleanliness) state.cleanliness = clamp100(state.cleanliness + effects.cleanliness)
77
+ if (effects.weightG) state.weightG = Math.max(400, state.weightG + effects.weightG)
78
+ if (effects.health) state.health = clamp(Math.round(state.health + effects.health), 0, MAX.health)
79
+ state.lastActiveAt = nowMs
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Passive diet
84
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ /**
3
+ * 新猪的初始状态。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/egg
7
+ */
8
+
9
+ import { DEFAULT_TIME_SCALE, MAX } from '../data.js'
10
+ import { BIRTH_WEIGHT_G, HATCH_WEIGHT_G, STATE_VERSION } from './constants.js'
11
+ import { remember } from './effects.js'
12
+ import { emptyDialogue } from './lines.js'
13
+ import { assignPersonality } from './profile.js'
14
+ import { roll } from './random.js'
15
+
16
+ export function layEgg(nowMs) {
17
+ return {
18
+ version: STATE_VERSION,
19
+ name: '猪猪',
20
+ bornAt: nowMs,
21
+ hatched: false,
22
+ dead: false,
23
+ /** When the pig died, so the grave can be left alone long enough for a soul. */
24
+ diedAt: null,
25
+ /** Last stage the panel announced; drives the "grew up" message. */
26
+ stage: 'box',
27
+ /** 加冕选的形态(data/evolution.js 的 key),null 是普通的猪。 */
28
+ form: null,
29
+ /** Accumulated pig time in ms — age is this, not wall clock. */
30
+ ageMs: 0,
31
+ /** 1 = the stage table is real months. See DEFAULT_TIME_SCALE. */
32
+ timeScale: DEFAULT_TIME_SCALE,
33
+ /** True when developer mode forced the age; the panel says so. */
34
+ ageForced: false,
35
+ xp: 0,
36
+ weightG: BIRTH_WEIGHT_G,
37
+ satiety: 70,
38
+ happiness: 70,
39
+ cleanliness: 90,
40
+ health: MAX.health,
41
+ // Enough to buy medicine on day one: at 60 a sick pig could not afford the
42
+ // cheapest cure and had nothing left to earn it with.
43
+ coins: 500,
44
+ inventory: {},
45
+ // 家当: dress items are bought once, owned forever, and worn.
46
+ dress: [],
47
+ worn: [],
48
+ traits: { intel: 0, charm: 0, strong: 0 },
49
+ // Lessons taken per subject (B4): the count decides the subject's stage
50
+ // and which jobs the pig qualifies for.
51
+ lessons: {},
52
+ // 兴趣课修读次数;满 CERTIFICATE_AFTER 次拿证。
53
+ interests: {},
54
+ souvenirs: [],
55
+ illness: null,
56
+ activity: null,
57
+ outingStreak: 0,
58
+ restMinutes: 0,
59
+ lastFedAt: 0,
60
+ lastActiveAt: nowMs,
61
+ lastSeenAt: nowMs,
62
+ cooldowns: {},
63
+ pending: [],
64
+ pendingSeq: 0,
65
+ dialogue: emptyDialogue(),
66
+ memories: [],
67
+ stats: {
68
+ turns: 0, messages: 0, tools: 0, toolErrors: 0, agentErrors: 0,
69
+ levelUps: 0, feeds: 0, baths: 0, plays: 0, pets: 0,
70
+ jobs: 0, coinsEarned: 0, purchases: 0, illnesses: 0, cures: 0, deaths: 0, revives: 0,
71
+ courses: 0, lessons: 0, trips: 0, sales: 0, interests: 0,
72
+ },
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Open the box. The piglet falls out — it does not start as a fully grown pig,
78
+ * and `ageDays` starts counting from the moment it does.
79
+ */
80
+ /**
81
+ * Open an existing box in place, keeping everything the save already has.
82
+ * `hatchEgg` builds a brand new pig; this one just lets the piglet out.
83
+ */
84
+ export function hatch(state, nowMs) {
85
+ state.hatched = true
86
+ // A new body starts at Lv1: anything the box picked up before it opened
87
+ // (older saves let it eat real work) does not count toward growing up.
88
+ state.xp = 0
89
+ state.sex = pickSex(state)
90
+ assignPersonality(state)
91
+ state.bornAt = nowMs
92
+ state.ageForced = false
93
+ state.dead = false
94
+ state.diedAt = null
95
+ state.stage = 'piglet'
96
+ state.weightG += HATCH_WEIGHT_G
97
+ state.health = Math.max(state.health, 1)
98
+ remember(state, '纸盒打开了,一只小猪蹦了出来 🐷', nowMs)
99
+ return state
100
+ }
101
+
102
+ export function hatchEgg(nowMs) {
103
+ const state = layEgg(nowMs)
104
+ state.hatched = true
105
+ state.sex = pickSex(state)
106
+ assignPersonality(state)
107
+ state.bornAt = nowMs
108
+ state.ageForced = false
109
+ state.weightG += HATCH_WEIGHT_G
110
+ state.stage = 'piglet'
111
+ remember(state, '纸盒打开了,一只小猪蹦了出来 🐷', nowMs)
112
+ return state
113
+ }
114
+
115
+ /**
116
+ * Boy or girl, 50/50, from the pig's own random sequence.
117
+ * @param {object} state
118
+ * @returns {'boy'|'girl'}
119
+ */
120
+ export const pickSex = state => (roll(state) < 0.5 ? 'boy' : 'girl')
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ /**
3
+ * 加冕:长成之后由主人选一种形态(data/evolution.js)。达标不会自动加冕,
4
+ * 不加钱、不改成长和疾病规则,只换样子。复活保留形态,领养的新猪从头来。
5
+ *
6
+ * 纯函数领域逻辑:时间由 nowMs 传入(见 docs/CONVENTIONS.md)。
7
+ * @module dsh-piggy/core/evolution
8
+ */
9
+
10
+ import { DEFAULT_FORM, FORMS, LIFE_STAGES, TRAITS, formByKey } from '../data.js'
11
+ import { levelFor, lifeStageFor } from './clock.js'
12
+ import { announce, remember } from './effects.js'
13
+ import { decay } from './settlement.js'
14
+
15
+ /** The level a stage starts at (成年猪 → 40). */
16
+ function stageLevel(stageKey) {
17
+ return LIFE_STAGES.find(stage => stage.key === stageKey)?.fromLevel ?? 0
18
+ }
19
+
20
+ /** One form's conditions, each with what the pig has now. */
21
+ function requirementsFor(state, form) {
22
+ const rows = [{ key: 'level', label: '等级', have: levelFor(state.xp), need: stageLevel(form.stage) }]
23
+ for (const [key, need] of Object.entries(form.requires)) {
24
+ rows.push({
25
+ key,
26
+ label: key === 'jobs' ? '打工' : (TRAITS[key]?.label ?? key),
27
+ have: key === 'jobs' ? (state.stats?.jobs ?? 0) : (state.traits?.[key] ?? 0),
28
+ need,
29
+ })
30
+ }
31
+ return rows.map(row => ({ ...row, met: row.have >= row.need }))
32
+ }
33
+
34
+ /**
35
+ * Every form and how close the pig is to it. `ready` means 加冕 would work now.
36
+ * @param {object} state
37
+ * @returns {{ current: string | null, forms: object[] } | null}
38
+ */
39
+ export function formsView(state) {
40
+ if (state === null || state.hatched !== true) return null
41
+ const alive = state.dead !== true
42
+ return {
43
+ current: formByKey(state.form)?.key ?? null,
44
+ forms: FORMS.map(form => {
45
+ const requirements = requirementsFor(state, form)
46
+ return {
47
+ key: form.key, label: form.label, emoji: form.emoji, art: form.art,
48
+ current: state.form === form.key,
49
+ ready: alive && state.form !== form.key && requirements.every(row => row.met),
50
+ requirements,
51
+ }
52
+ }),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 加冕. Settles elapsed time first, so a pig that died while the panel was
58
+ * closed cannot be crowned. Crowning into the form it already has is a no-op.
59
+ * @param {object} state
60
+ * @param {number} nowMs
61
+ * @param {string} [formKey]
62
+ */
63
+ export function crown(state, nowMs, formKey = DEFAULT_FORM) {
64
+ if (state === null) return { ok: false, reason: 'absent' }
65
+ const form = formByKey(formKey)
66
+ if (form === null) return { ok: false, reason: 'unknown' }
67
+ if (state.hatched !== true) return { ok: false, reason: 'box' }
68
+ decay(state, nowMs)
69
+ if (state.dead === true) return { ok: false, reason: 'dead' }
70
+ if (state.form === form.key) return { ok: true, form: form.key }
71
+ const missing = requirementsFor(state, form).filter(row => !row.met)
72
+ if (missing.length > 0) return { ok: false, reason: 'coronation-ineligible', missing }
73
+ state.form = form.key
74
+ remember(state, `👑 加冕成为${form.label},本事和生活都照旧`, nowMs)
75
+ announce(state, 'coronation', `${state.name} 加冕成为${form.label}!`, nowMs)
76
+ return { ok: true, form: form.key }
77
+ }
78
+
79
+ /**
80
+ * The life stage with the chosen form laid over it: same key and size, the
81
+ * form's name, line and sprite. The grave and earlier stages are untouched.
82
+ * @param {object} state
83
+ * @param {number} nowMs
84
+ */
85
+ export function formStageView(state, nowMs) {
86
+ const life = lifeStageFor(state, nowMs)
87
+ const form = state === null ? null : formByKey(state.form)
88
+ if (form === null || life.key !== form.stage) return { ...life, actionArt: false, hides: [] }
89
+ return { ...life, label: form.label, art: form.art, line: form.line, actionArt: form.actionArt, hides: [...form.hides] }
90
+ }
@@ -0,0 +1,110 @@
1
+ // @ts-check
2
+ /**
3
+ * 成长:时间自己长、照顾好坏定快慢、陪主人干活有加成、升级和换形态。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * 数字见 data/growth.js(docs/tasks/numbers/B2-growth.md)。
7
+ * @module dsh-piggy/core/growth
8
+ */
9
+
10
+ import {
11
+ DSH_GROWTH,
12
+ DSH_GROWTH_DAILY_CAP,
13
+ GROWTH_PER_HOUR,
14
+ MAX,
15
+ MIN_GROWTH_FACTOR,
16
+ MOOD_GROWTH_FACTORS,
17
+ NEGLECT_GROWTH_FACTOR,
18
+ SICK_GROWTH_PENALTY_PER_HEALTH,
19
+ THRESHOLDS,
20
+ WORK_GROWTH_PER_HOUR,
21
+ } from '../data.js'
22
+ import { dayKeyFor, levelFor, levelTitle, lifeStageFor } from './clock.js'
23
+ import { announce, remember } from './effects.js'
24
+ import { say } from './lines.js'
25
+ import { noteToday } from './diary.js'
26
+
27
+ /**
28
+ * How fast the pig is growing right now, as a multiplier on the base rate.
29
+ * Mood sets the band; being hungry or dirty and being ill each take a further
30
+ * cut. The factors multiply, and never drop below MIN_GROWTH_FACTOR.
31
+ * @param {object} state
32
+ * @returns {number}
33
+ */
34
+ export function careFactor(state) {
35
+ const mood = MOOD_GROWTH_FACTORS.find(band => state.happiness >= band.min) ?? MOOD_GROWTH_FACTORS.at(-1)
36
+ let factor = mood?.factor ?? 1
37
+ if (state.satiety < THRESHOLDS.hungry || state.cleanliness < THRESHOLDS.dirty) factor *= NEGLECT_GROWTH_FACTOR
38
+ if (state.illness !== null && state.illness !== undefined) {
39
+ const lost = Math.max(0, MAX.health - state.health)
40
+ factor *= Math.max(0, 1 - lost * SICK_GROWTH_PENALTY_PER_HEALTH)
41
+ }
42
+ return Math.max(MIN_GROWTH_FACTOR, factor)
43
+ }
44
+
45
+ /**
46
+ * Add growth and announce whatever it crosses: each level, a new title, a new
47
+ * body. The only way `xp` should ever go up.
48
+ * @param {object} state
49
+ * @param {number} amount
50
+ * @param {number} nowMs
51
+ */
52
+ export function grow(state, amount, nowMs) {
53
+ if (!(amount > 0) || state.hatched !== true || state.dead === true) return
54
+ const before = levelFor(state.xp)
55
+ const stageBefore = state.stage
56
+ state.xp = (Number.isFinite(state.xp) ? state.xp : 0) + amount
57
+ const after = levelFor(state.xp)
58
+ if (after > before) announceLevelUp(state, before, after, nowMs)
59
+ const stage = lifeStageFor(state, nowMs)
60
+ if (stage.key !== stageBefore) {
61
+ state.stage = stage.key
62
+ remember(state, `长成了${stage.label} ${stage.emoji}`, nowMs)
63
+ announce(state, 'stage', `${state.name} 长成了${stage.label} ${stage.emoji}`, nowMs)
64
+ noteToday(state, 'stage')
65
+ say(state, 'growUp', nowMs)
66
+ }
67
+ }
68
+
69
+ function announceLevelUp(state, before, after, nowMs) {
70
+ state.stats.levelUps = (state.stats.levelUps ?? 0) + (after - before)
71
+ noteToday(state, 'levelUp', after - before)
72
+ const title = levelTitle(after)
73
+ const newTitle = levelTitle(before).level !== title.level
74
+ remember(state, `升到 Lv.${after}${newTitle ? `,成了「${title.label}」` : ''}`, nowMs)
75
+ announce(state, 'levelup', newTitle
76
+ ? `${state.name} 升到 Lv.${after},成了「${title.label}」${title.emoji}`
77
+ : `${state.name} 升到 Lv.${after} ✨`, nowMs)
78
+ say(state, 'levelup', nowMs)
79
+ }
80
+
81
+ /**
82
+ * Growth from time passing: the base rate times the care factor, over pig time
83
+ * (so the debug page's time scale speeds it up along with age).
84
+ * @param {object} state
85
+ * @param {number} pigMs - elapsed pig time in this step
86
+ * @param {number} atMs
87
+ */
88
+ export function growWithTime(state, pigMs, atMs) {
89
+ grow(state, (pigMs / 3_600_000) * GROWTH_PER_HOUR * careFactor(state), atMs)
90
+ }
91
+
92
+ /**
93
+ * The bonus for keeping the owner company while they really work — a turn
94
+ * ended, a tool ran — up to DSH_GROWTH_DAILY_CAP a day.
95
+ * @param {object} state
96
+ * @param {string} event - a DIET key; only those in DSH_GROWTH count
97
+ * @param {number} nowMs
98
+ */
99
+ export function growFromRealWork(state, event, nowMs) {
100
+ const amount = DSH_GROWTH[event]
101
+ if (amount === undefined || state.hatched !== true || state.dead === true) return
102
+ const day = dayKeyFor(nowMs)
103
+ const today = state.realWorkGrowth?.day === day ? state.realWorkGrowth.amount : 0
104
+ const granted = Math.min(amount, Math.max(0, DSH_GROWTH_DAILY_CAP - today))
105
+ state.realWorkGrowth = { day, amount: today + granted }
106
+ grow(state, granted, nowMs)
107
+ }
108
+
109
+ /** Growth for time spent out working or travelling. */
110
+ export const outingGrowth = minutes => Math.round((minutes / 60) * WORK_GROWTH_PER_HOUR)