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,188 @@
1
+ // @ts-check
2
+ /**
3
+ * 疾病链、药与发病 —— 静态数值表(零逻辑、零 IO,见 docs/CONVENTIONS.md)。
4
+ *
5
+ * 数字全部来自 docs/tasks/numbers/B3-illness.md(用户 2026-10-01 确认)。
6
+ * 链与药名照 QQ 宠物怀旧服 State.js;头晕、皮肤两条是原版有药没接上的链,
7
+ * 头晕缺的第 3 级补了「神经衰弱 · 噗噗神水」。
8
+ *
9
+ * @module dsh-piggy/data/illness
10
+ */
11
+
12
+ /** 状态阈值:饿、脏、孤单(心情)。发病、成长、心情显示共用。 */
13
+ export const THRESHOLDS = Object.freeze({
14
+ hungry: 25,
15
+ dirty: 35,
16
+ lonely: 35,
17
+ })
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Illness is measured in DAYS, not in minutes. A stage used to last 25 minutes
21
+ // and a cold killed the pig inside two hours unless you were watching.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * How long each stage lasts, in hours, indexed by stage - 1. Untreated, the
26
+ * ladder runs 1 + 1.5 + 2 + 3 days.
27
+ */
28
+ export const ILLNESS_STAGE_HOURS = Object.freeze([24, 36, 48, 72])
29
+
30
+ export const ILLNESS_STAGE_MINUTES = ILLNESS_STAGE_HOURS[0] * 60
31
+
32
+ /** Milliseconds one stage lasts. */
33
+ export const illnessStageMs = stage => (ILLNESS_STAGE_HOURS[stage - 1] ?? 24) * 3_600_000
34
+
35
+ /**
36
+ * Chance an untreated illness shakes itself off when a stage would otherwise
37
+ * pass, by stage. The last stage never does — it needs medicine or it is fatal.
38
+ */
39
+ export const SELF_HEAL_CHANCE = Object.freeze([0.25, 0.12, 0.05, 0])
40
+
41
+ /** A sick pig works at half pay, so being ill has a cost without being a wall. */
42
+ export const SICK_PAY_MULTIPLIER = 0.5
43
+
44
+ /** Being out while ill runs the illness clock twice as fast. */
45
+ export const SICK_AWAY_MULTIPLIER = 2
46
+
47
+ export const SLEEPY_AFTER_MINUTES = 30
48
+
49
+ /** Away from home the pig burns through its bars faster. */
50
+ export const AWAY_MULTIPLIER = 1.8
51
+
52
+ /** Health left at each illness stage (1..4): 4, 3, 2, 1. */
53
+ export const STAGE_HEALTH = Object.freeze([4, 3, 2, 1])
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Medicine: one per illness stage, priced by how bad the stage is.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** 1–4 级药的价格。 */
60
+ export const MEDICINE_PRICE_BY_TIER = Object.freeze([30, 70, 140, 260])
61
+
62
+ const medicine = (key, label, emoji, tier) =>
63
+ Object.freeze({ key, label, emoji, tier, price: MEDICINE_PRICE_BY_TIER[tier - 1], kind: 'medicine' })
64
+
65
+ const stage = (name, cure) => Object.freeze({ name, cure })
66
+
67
+ /**
68
+ * Five chains, four stages each. `cure` is the medicine item for that stage;
69
+ * `cause` is what tends to bring the chain on (see ILLNESS_ONSET).
70
+ */
71
+ export const ILLNESS_CHAINS = Object.freeze([
72
+ Object.freeze({
73
+ key: 'cold', name: '感冒', emoji: '🤧', cause: '饿',
74
+ stages: Object.freeze([
75
+ stage('感冒', medicine('banlangen', '板蓝根', '🌿', 1)),
76
+ stage('发烧', medicine('tuishaoyao', '退烧药', '💊', 2)),
77
+ stage('重感冒', medicine('yinqiaowan', '银翘丸', '🟤', 3)),
78
+ stage('肺炎', medicine('jinse-xiaoyan', '金色消炎水', '🧪', 4)),
79
+ ]),
80
+ }),
81
+ Object.freeze({
82
+ key: 'cough', name: '咳嗽', emoji: '😷', cause: '脏',
83
+ stages: Object.freeze([
84
+ stage('咳嗽', medicine('pipa-syrup', '枇杷糖浆', '🍯', 1)),
85
+ stage('支气管炎', medicine('gancaoji', '甘草剂', '🌾', 2)),
86
+ stage('哮喘', medicine('dingchuanwan', '定喘丸', '⚪', 3)),
87
+ stage('肺结核', medicine('tongfengsan', '通风散', '🫙', 4)),
88
+ ]),
89
+ }),
90
+ Object.freeze({
91
+ key: 'stomach', name: '肠胃', emoji: '🤢', cause: '吃撑',
92
+ stages: Object.freeze([
93
+ stage('肚子胀', medicine('xiaoshipian', '消食片', '💊', 1)),
94
+ stage('胃炎', medicine('lanse-xiaoyan', '蓝色消炎水', '🧪', 2)),
95
+ stage('胃溃疡', medicine('longdancao', '龙胆草', '🌱', 3)),
96
+ stage('胃癌', medicine('xianrentang', '仙人汤', '🍵', 4)),
97
+ ]),
98
+ }),
99
+ Object.freeze({
100
+ key: 'dizzy', name: '头晕', emoji: '😵', cause: '心情差、连续打工上课',
101
+ stages: Object.freeze([
102
+ stage('头晕', medicine('qingliangyou', '清凉油', '🟢', 1)),
103
+ stage('偏头痛', medicine('zhitongpian', '止痛片', '💊', 2)),
104
+ stage('神经衰弱', medicine('pupu-shenshui', '噗噗神水', '🫧', 3)),
105
+ stage('心力衰竭', medicine('heshouwu', '何首乌', '🥔', 4)),
106
+ ]),
107
+ }),
108
+ Object.freeze({
109
+ key: 'skin', name: '皮肤', emoji: '🩹', cause: '很脏',
110
+ stages: Object.freeze([
111
+ stage('瘙痒', medicine('runfulu', '润肤露', '🧴', 1)),
112
+ stage('干裂', medicine('bohe-you', '薄荷油', '🍃', 2)),
113
+ stage('溃疡', medicine('shengjigao', '生肌膏', '🩹', 3)),
114
+ stage('感染', medicine('chashu-you', '茶树油', '🌳', 4)),
115
+ ]),
116
+ }),
117
+ ])
118
+
119
+ /** Chain index by key. */
120
+ export const CHAIN_INDEX = Object.freeze(Object.fromEntries(ILLNESS_CHAINS.map((chain, index) => [chain.key, index])))
121
+
122
+ /** 百草丹:任何病、任何一级都能治。 */
123
+ export const CURE_ALL = Object.freeze({ key: 'baicaodan', label: '百草丹', emoji: '🌿', price: 500, kind: 'medicine', cureAll: true })
124
+
125
+ /** Every medicine on the shelf: the twenty stage cures, then 百草丹. */
126
+ export const MEDICINES = Object.freeze([
127
+ ...ILLNESS_CHAINS.flatMap(chain => chain.stages.map(entry => entry.cure)),
128
+ CURE_ALL,
129
+ ])
130
+
131
+ /** 还魂丹:复活,并治好所有病。 */
132
+ export const REVIVE_ITEM = Object.freeze({ key: 'soul', label: '还魂丹', emoji: '✨', price: 800, kind: 'revive' })
133
+
134
+ /** 看医生:不用自己买药,按对症药价的这个倍数收费直接治好。 */
135
+ export const DOCTOR_MARKUP = 1.5
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Falling ill: a chance each hour at home, not a certainty.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * 2026-10-01 用户要求病得慢一点:饿、脏各从 8%/h 降到 3%/h
143
+ * (又饿又脏时中位数约 4 小时 → 约 11 小时)。
144
+ */
145
+ export const ILLNESS_ONSET = Object.freeze({
146
+ /** Even a well-kept pig: about one small illness every three weeks. */
147
+ basePerHour: 0.002,
148
+ /** Hungry (satiety < THRESHOLDS.hungry) → 感冒. */
149
+ hungryPerHour: 0.03,
150
+ /** Dirty (cleanliness < THRESHOLDS.dirty) → 咳嗽; below veryDirty → 皮肤. */
151
+ dirtyPerHour: 0.03,
152
+ veryDirty: 15,
153
+ /** Mood below sadBelow → 头晕. */
154
+ sadBelow: 30,
155
+ sadPerHour: 0.05,
156
+ /** This many outings in a row with no rest between → 头晕. */
157
+ overworkStreak: 3,
158
+ overworkPerHour: 0.05,
159
+ /** Minutes at home that count as a rest and reset the streak. */
160
+ restMinutes: 60,
161
+ /** Feeding a pig already at overfullAt satiety → 肠胃, with this chance. */
162
+ overfullAt: 95,
163
+ overfeedChance: 0.25,
164
+ })
165
+
166
+ /**
167
+ * The illness at `chainIndex`/`stageNumber`, with its cure spelled out.
168
+ * @returns {{chain: string, chainKey: string, stage: number, name: string, cure: string, cureKey: string, cureEmoji: string, curePrice: number, health: number}|null}
169
+ */
170
+ export function illnessAt(chainIndex, stageNumber) {
171
+ const chain = ILLNESS_CHAINS[chainIndex]
172
+ if (chain === undefined) return null
173
+ const entry = chain.stages[stageNumber - 1]
174
+ if (entry === undefined) return null
175
+ return {
176
+ chain: chain.name,
177
+ chainKey: chain.key,
178
+ stage: stageNumber,
179
+ name: entry.name,
180
+ cure: entry.cure.label,
181
+ cureKey: entry.cure.key,
182
+ cureEmoji: entry.cure.emoji,
183
+ curePrice: entry.cure.price,
184
+ health: STAGE_HEALTH[stageNumber - 1],
185
+ }
186
+ }
187
+
188
+ export const nextIllness = (chainIndex, stageNumber) => illnessAt(chainIndex, stageNumber + 1)
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+ /**
3
+ * 兴趣课与证书 —— 静态数值表(零逻辑、零 IO,见 docs/CONVENTIONS.md)。
4
+ *
5
+ * B4(用户 2026-10-01 确认):兴趣课保留,同一门上满 CERTIFICATE_AFTER 次拿证;
6
+ * 摄影师、程序员、舞蹈家、教练这几份工作要凭证上岗。
7
+ *
8
+ * @module dsh-piggy/data/interests
9
+ */
10
+
11
+ import { MINUTES } from './minutes.js'
12
+
13
+ /** 同一门兴趣课上满这么多次就拿到证书。 */
14
+ export const CERTIFICATE_AFTER = 5
15
+
16
+ export const INTERESTS = Object.freeze([
17
+ Object.freeze({ key: 'photography', label: '摄影', emoji: '📷', trait: 'charm', minutes: MINUTES.half, cost: 40, gain: 2, certificate: '摄影证', blurb: '会拍照的猪,走到哪都上相' }),
18
+ Object.freeze({ key: 'coding', label: '编程', emoji: '💻', trait: 'intel', minutes: MINUTES.hour, cost: 80, gain: 2, certificate: '编程证', blurb: '学会让别的猪干活' }),
19
+ Object.freeze({ key: 'dancing', label: '跳舞', emoji: '💃', trait: 'charm', minutes: MINUTES.half, cost: 45, gain: 2, certificate: '跳舞证', blurb: '会跳舞的猪不怯场' }),
20
+ Object.freeze({ key: 'fitness', label: '健身', emoji: '🏋', trait: 'strong', minutes: MINUTES.half, cost: 35, gain: 2, certificate: '健身证', blurb: '举得动更重的东西' }),
21
+ // --- 2026-10-01 用户要求多一些:每一维各加四门,证书先当收藏,以后可以挂新职业 ---
22
+ // 🧠 智力
23
+ Object.freeze({ key: 'weiqi', label: '围棋', emoji: '♟️', trait: 'intel', minutes: MINUTES.half, cost: 45, gain: 2, certificate: '围棋证', blurb: '下棋的时候一动不动,像睡着了' }),
24
+ Object.freeze({ key: 'english', label: '英语', emoji: '🔤', trait: 'intel', minutes: MINUTES.half, cost: 40, gain: 2, certificate: '英语证', blurb: 'Oink oink, hello' }),
25
+ Object.freeze({ key: 'astronomy', label: '天文', emoji: '🔭', trait: 'intel', minutes: MINUTES.hour, cost: 90, gain: 2, certificate: '天文证', blurb: '认得出哪颗星星像猪鼻子' }),
26
+ Object.freeze({ key: 'cube', label: '魔方', emoji: '🧩', trait: 'intel', minutes: MINUTES.half, cost: 35, gain: 2, certificate: '魔方证', blurb: '蹄子转得比手还快' }),
27
+ // ✨ 魅力
28
+ Object.freeze({ key: 'calligraphy', label: '书法', emoji: '🖌️', trait: 'charm', minutes: MINUTES.half, cost: 40, gain: 2, certificate: '书法证', blurb: '写的「猪」字特别有神' }),
29
+ Object.freeze({ key: 'guitar', label: '吉他', emoji: '🎸', trait: 'charm', minutes: MINUTES.half, cost: 50, gain: 2, certificate: '吉他证', blurb: '会弹三个和弦就够用了' }),
30
+ Object.freeze({ key: 'magic', label: '魔术', emoji: '🎩', trait: 'charm', minutes: MINUTES.hour, cost: 85, gain: 2, certificate: '魔术证', blurb: '能从帽子里变出一个苹果' }),
31
+ Object.freeze({ key: 'ikebana', label: '插花', emoji: '💐', trait: 'charm', minutes: MINUTES.half, cost: 35, gain: 2, certificate: '插花证', blurb: '插着插着把花吃了' }),
32
+ // 💪 武力
33
+ Object.freeze({ key: 'swimming', label: '游泳', emoji: '🏊', trait: 'strong', minutes: MINUTES.half, cost: 40, gain: 2, certificate: '游泳证', blurb: '会狗刨,不对,猪刨' }),
34
+ Object.freeze({ key: 'skating', label: '轮滑', emoji: '🛼', trait: 'strong', minutes: MINUTES.half, cost: 45, gain: 2, certificate: '轮滑证', blurb: '四只蹄子,四双鞋' }),
35
+ Object.freeze({ key: 'climbing', label: '攀岩', emoji: '🧗', trait: 'strong', minutes: MINUTES.hour, cost: 90, gain: 2, certificate: '攀岩证', blurb: '爬得上去,下来要人抱' }),
36
+ Object.freeze({ key: 'football', label: '足球', emoji: '⚽', trait: 'strong', minutes: MINUTES.half, cost: 35, gain: 2, certificate: '足球证', blurb: '最喜欢用头顶球' }),
37
+ ])
38
+
39
+ export const interestByKey = key => INTERESTS.find(entry => entry.key === key) ?? null
@@ -0,0 +1,150 @@
1
+ // @ts-check
2
+ /**
3
+ * 职业与门槛 —— 静态数值表(零逻辑、零 IO,见 docs/CONVENTIONS.md)。
4
+ *
5
+ * 数字全部来自 docs/tasks/numbers/B4-study-jobs.md(用户 2026-10-01 确认):
6
+ * 原版 18 种 + 新增 15 种,门槛 = 等级 + 某几门课的课时(+ 兴趣证书)。
7
+ * 三维不再是门槛,只决定报酬加成(`trait` 是算加成用的那一维)。
8
+ *
9
+ * @module dsh-piggy/data/jobs
10
+ */
11
+
12
+ import { CERTIFICATE_AFTER, interestByKey } from './interests.js'
13
+ import { SUBJECTS, subjectByKey } from './school.js'
14
+ import { TRAITS } from './traits.js'
15
+
16
+ /**
17
+ * @typedef {object} JobRequires
18
+ * @property {number} level
19
+ * @property {Readonly<Record<string, number>>} [lessons] - 某几门课各要多少节
20
+ * @property {number} [every] - 九门课每门都要这么多节
21
+ * @property {{count: number, lessons: number}} [anyOf] - 任意 count 门课达到 lessons 节
22
+ * @property {string} [certificate] - 要哪门兴趣课的证书(interest key)
23
+ */
24
+
25
+ /** 每班饱食 / 清洁消耗,按时长分档(沿用 B4 以前的消耗比例)。 */
26
+ const COST_BY_MINUTES = Object.freeze({
27
+ 30: Object.freeze({ satiety: -6, cleanliness: -4 }),
28
+ 45: Object.freeze({ satiety: -8, cleanliness: -6 }),
29
+ 60: Object.freeze({ satiety: -15, cleanliness: -12 }),
30
+ 120: Object.freeze({ satiety: -18, cleanliness: -10 }),
31
+ 240: Object.freeze({ satiety: -34, cleanliness: -26 }),
32
+ 480: Object.freeze({ satiety: -60, cleanliness: -40 }),
33
+ })
34
+
35
+ /**
36
+ * @param {string} key
37
+ * @param {string} label
38
+ * @param {string} emoji
39
+ * @param {'intel'|'charm'|'strong'} trait
40
+ * @param {number} minutes
41
+ * @param {number} coins
42
+ * @param {JobRequires} requires
43
+ */
44
+ const job = (key, label, emoji, trait, minutes, coins, requires) =>
45
+ Object.freeze({ key, label, emoji, trait, minutes, coins, ...COST_BY_MINUTES[minutes], requires: Object.freeze(requires) })
46
+
47
+ export const JOBS = Object.freeze([
48
+ // --- 起步:不用上学 · 30–45 分钟 ------------------------------------------
49
+ job('bricks', '搬砖', '🧱', 'strong', 30, 40, { level: 1 }),
50
+ job('flyers', '发传单', '📄', 'charm', 30, 40, { level: 1 }),
51
+ job('dishes', '洗碗工', '🍽', 'strong', 30, 45, { level: 3 }),
52
+ job('delivery', '送外卖', '🛵', 'strong', 45, 70, { level: 5, lessons: { pe: 3 } }),
53
+ // --- 小学毕业(某门课 9 节)· 1 小时 --------------------------------------
54
+ job('mason', '泥瓦工', '🧱', 'strong', 60, 150, { level: 3, lessons: { labour: 9 } }),
55
+ job('cashier', '收银员', '🧾', 'intel', 60, 150, { level: 5, lessons: { mathematics: 9 } }),
56
+ job('florist', '花匠', '💐', 'charm', 60, 160, { level: 6, lessons: { manners: 9 } }),
57
+ job('carpenter', '木匠', '🪚', 'strong', 60, 160, { level: 6, lessons: { labour: 9 } }),
58
+ job('courier', '快递员', '📦', 'strong', 60, 170, { level: 8, lessons: { pe: 9, wushu: 9 } }),
59
+ job('gardener', '园丁', '🌳', 'charm', 60, 180, { level: 9, lessons: { chinese: 9, art: 9 } }),
60
+ job('guard', '保安', '🛡', 'strong', 60, 180, { level: 9, lessons: { politics: 9, wushu: 9 } }),
61
+ job('actor', '演员', '🎭', 'charm', 60, 180, { level: 9, lessons: { manners: 9, labour: 9 } }),
62
+ // --- 中学毕业(20 节)· 2 小时 --------------------------------------------
63
+ job('chef', '厨师', '👨‍🍳', 'strong', 120, 480, { level: 12, lessons: { labour: 20, manners: 9 } }),
64
+ job('singer', '歌手', '🎤', 'charm', 120, 500, { level: 12, lessons: { music: 20 } }),
65
+ job('lawyer', '律师', '⚖️', 'intel', 120, 520, { level: 12, lessons: { politics: 20 } }),
66
+ job('nurse', '护士', '💉', 'charm', 120, 520, { level: 14, lessons: { chinese: 20, manners: 20 } }),
67
+ job('athlete', '运动员', '🏅', 'strong', 120, 540, { level: 14, lessons: { pe: 20, wushu: 20 } }),
68
+ job('cartoonist', '漫画家', '✏️', 'charm', 120, 560, { level: 15, lessons: { art: 20, labour: 20 } }),
69
+ job('police', '警察', '👮', 'strong', 120, 560, { level: 15, lessons: { politics: 20, wushu: 20 } }),
70
+ job('songwriter', '词曲作者', '🎼', 'charm', 120, 560, { level: 15, lessons: { chinese: 20, music: 20 } }),
71
+ // --- 大学毕业(40 节)· 4 小时 --------------------------------------------
72
+ job('editor', '编辑', '📰', 'intel', 240, 1500, { level: 18, lessons: { chinese: 40 } }),
73
+ job('photographer', '摄影师', '📷', 'charm', 240, 1600, { level: 18, lessons: { art: 40 }, certificate: 'photography' }),
74
+ job('coach', '教练', '🏋', 'strong', 240, 1500, { level: 20, lessons: { pe: 40 }, certificate: 'fitness' }),
75
+ job('programmer', '程序员', '💻', 'intel', 240, 1800, { level: 22, lessons: { mathematics: 40 }, certificate: 'coding' }),
76
+ job('dancer', '舞蹈家', '💃', 'charm', 240, 1800, { level: 22, lessons: { music: 40, pe: 40 }, certificate: 'dancing' }),
77
+ job('architect', '建筑师', '📐', 'intel', 240, 1800, { level: 24, lessons: { art: 40, mathematics: 40 } }),
78
+ job('doctor', '医生', '🩺', 'intel', 240, 2000, { level: 26, lessons: { chinese: 40, mathematics: 40, politics: 20 } }),
79
+ // --- 研究生(95 节,或九门都到 40)· 8 小时 -------------------------------
80
+ job('scientist', '科研人员', '🔬', 'intel', 480, 4800, { level: 30, lessons: { chinese: 40, mathematics: 40, art: 40, pe: 40 } }),
81
+ job('official', '公务员', '🏛', 'intel', 480, 5200, { level: 35, every: 40 }),
82
+ job('professor', '大学教授', '👨‍🏫', 'intel', 480, 5600, { level: 40, anyOf: { count: 3, lessons: 95 } }),
83
+ job('star', '明星', '🌟', 'charm', 480, 6000, { level: 40, lessons: { music: 95, manners: 95, art: 40 } }),
84
+ job('astronaut', '宇航员', '🚀', 'strong', 480, 6400, { level: 45, lessons: { mathematics: 95, pe: 95, wushu: 40 } }),
85
+ job('ceo', '总裁', '💼', 'intel', 480, 8000, { level: 50, lessons: { mathematics: 95, chinese: 95, manners: 95, politics: 40 } }),
86
+ ])
87
+
88
+ export const jobByKey = key => JOBS.find(entry => entry.key === key) ?? null
89
+
90
+ /**
91
+ * @typedef {object} Condition
92
+ * @property {'level'|'lesson'|'every'|'anyOf'|'certificate'} kind
93
+ * @property {string} text - 给面板看的一小段,如「🔢数学 9 节」
94
+ * @property {number} need
95
+ * @property {number} have
96
+ * @property {boolean} met
97
+ */
98
+
99
+ /**
100
+ * Every condition a job has, met or not — the panel's 详情 lists them all
101
+ * with a tick or a cross, rather than only what is missing.
102
+ * @param {{requires: JobRequires}} target
103
+ * @param {{level: number, lessons: Record<string, number>, interests: Record<string, number>}} pig
104
+ * @returns {Condition[]}
105
+ */
106
+ export function jobChecklist(target, pig) {
107
+ const need = target.requires
108
+ /** @type {Condition[]} */
109
+ const out = []
110
+ const lessonsOf = key => pig.lessons?.[key] ?? 0
111
+ out.push({ kind: 'level', text: `Lv.${need.level}`, need: need.level, have: pig.level, met: pig.level >= need.level })
112
+ for (const [key, count] of Object.entries(need.lessons ?? {})) {
113
+ const subject = subjectByKey(key)
114
+ if (subject === null) continue
115
+ const have = lessonsOf(key)
116
+ out.push({ kind: 'lesson', text: `${subject.emoji}${subject.label} ${count} 节`, need: count, have, met: have >= count })
117
+ }
118
+ if (need.every !== undefined) {
119
+ const every = need.every
120
+ const reached = SUBJECTS.filter(subject => lessonsOf(subject.key) >= every).length
121
+ out.push({ kind: 'every', text: `九门课各 ${every} 节`, need: SUBJECTS.length, have: reached, met: reached >= SUBJECTS.length })
122
+ }
123
+ if (need.anyOf !== undefined) {
124
+ const { count, lessons } = need.anyOf
125
+ const reached = SUBJECTS.filter(subject => lessonsOf(subject.key) >= lessons).length
126
+ out.push({ kind: 'anyOf', text: `任意 ${count} 门课各 ${lessons} 节`, need: count, have: reached, met: reached >= count })
127
+ }
128
+ if (need.certificate !== undefined) {
129
+ const interest = interestByKey(need.certificate)
130
+ const have = pig.interests?.[need.certificate] ?? 0
131
+ if (interest !== null) out.push({ kind: 'certificate', text: `${interest.emoji}${interest.certificate}`, need: CERTIFICATE_AFTER, have, met: have >= CERTIFICATE_AFTER })
132
+ }
133
+ return out
134
+ }
135
+
136
+ /**
137
+ * Everything a job asks for that the pig does not have yet — all of it, not
138
+ * just the first, so a locked job can say exactly why.
139
+ * @param {{requires: JobRequires}|null} target
140
+ * @param {{level: number, lessons: Record<string, number>, interests: Record<string, number>}} pig
141
+ * @returns {{ok: boolean, missing: Condition[]}|null}
142
+ */
143
+ export function jobRequirement(target, pig) {
144
+ if (target === null || target === undefined) return null
145
+ const missing = jobChecklist(target, pig).filter(condition => !condition.met)
146
+ return { ok: missing.length === 0, missing }
147
+ }
148
+
149
+ /** The trait a job pays out on, spelled out for the panel. */
150
+ export const jobTrait = target => TRAITS[target.trait]
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ /**
3
+ * 生命周期与等级 —— 静态数值表(零逻辑、零 IO,见 docs/CONVENTIONS.md)。
4
+ * @module dsh-piggy/data/life
5
+ */
6
+
7
+ /** Attribute ceilings. `health` keeps QQ Pet's 5-point scale. */
8
+ export const MAX = Object.freeze({ satiety: 100, happiness: 100, cleanliness: 100, health: 5 })
9
+
10
+ /** One "pig month" — what the stage table below counts in. */
11
+ export const DAYS_PER_MONTH = 30
12
+
13
+ /**
14
+ * @typedef {object} LifeStage
15
+ * @property {string} key
16
+ * @property {string} label
17
+ * @property {string} emoji
18
+ * @property {number} size
19
+ * @property {number} [fromLevel] - 到这一级就换成这个形态
20
+ * @property {string} line
21
+ * @property {boolean} [box]
22
+ * @property {string} [art]
23
+ * @property {boolean} [faded]
24
+ */
25
+ /**
26
+ * 形态按**等级**换,不按年龄(B2,用户 2026-10-01 确认):幼年 → 青年 Lv10(约 5 天),
27
+ * 青年 → 成年 Lv40(约 2.7 个月)。没有老年、没有寿命 —— 猪只会因为意外死掉。
28
+ * key 沿用旧的 piglet/young/middle,美术和样式都挂在 key 上。
29
+ * @type {ReadonlyArray<LifeStage>}
30
+ */
31
+ export const LIFE_STAGES = Object.freeze([
32
+ Object.freeze({
33
+ key: 'box', label: '纸盒', emoji: '📦', size: 58, fromLevel: 0, box: true,
34
+ line: '一个纸盒,侧面戳了几个透气孔',
35
+ }),
36
+ Object.freeze({
37
+ key: 'piglet', label: '幼年猪', emoji: '🐖', art: 'piglet', size: 54, fromLevel: 1,
38
+ line: '刚从纸盒里蹦出来,圆头圆脑',
39
+ }),
40
+ Object.freeze({
41
+ key: 'young', label: '青年猪', emoji: '🐖', size: 60, fromLevel: 10,
42
+ line: '长开了,走路带风',
43
+ }),
44
+ Object.freeze({
45
+ key: 'middle', label: '成年猪', emoji: '🐖', size: 68, fromLevel: 40,
46
+ line: '很有分量,会一屁股坐住你的椅子',
47
+ }),
48
+ ])
49
+
50
+ /**
51
+ * Time multiplier for the debug page. Pig time — age and passive growth —
52
+ * runs this many times faster than the wall clock. 1 = real time.
53
+ */
54
+ export const DEFAULT_TIME_SCALE = 1
55
+
56
+ /** Presets offered in the panel, so the number is never typed. */
57
+ export const TIME_SCALES = Object.freeze([1, 12, 30, 60])
58
+
59
+ /** The tombstone and the soul that settles on an unclaimed one. */
60
+ /** @type {LifeStage} */
61
+ export const GRAVE = Object.freeze({ key: 'grave', label: '墓碑', emoji: '🪦', size: 56, line: '这里躺着一只猪' })
62
+
63
+ export const SOUL = Object.freeze({ emoji: '👻', label: '灵魂' })
64
+
65
+ /** How long a grave is left alone before the soul turns up. */
66
+ export const SOUL_AFTER_DAYS = 1
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Level — the curve and the sources of growth live in data/growth.js.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Titles, earned by level. The last one that applies wins. */
73
+ export const LEVEL_TITLES = Object.freeze([
74
+ Object.freeze({ level: 1, label: '新来的', emoji: '🌱' }),
75
+ Object.freeze({ level: 5, label: '熟面孔', emoji: '🙂' }),
76
+ Object.freeze({ level: 10, label: '老伙计', emoji: '🤝' }),
77
+ Object.freeze({ level: 20, label: '镇宅之猪', emoji: '🏠' }),
78
+ Object.freeze({ level: 35, label: '十里八乡有名', emoji: '📣' }),
79
+ Object.freeze({ level: 50, label: '传说', emoji: '🌟' }),
80
+ Object.freeze({ level: 80, label: '神话', emoji: '👑' }),
81
+ ])
82
+
83
+ export const lifeStageByKey = key => LIFE_STAGES.find(stage => stage.key === key) ?? null
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Time — QQ Pet hands out timers measured in hours, not seconds. The desktop
87
+ // pet sits in a corner for a working day; the pig should too.
88
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,198 @@
1
+ // @ts-check
2
+ /**
3
+ * 台词 —— 按场景分类,带可选的回复按钮(零逻辑、零 IO,见 docs/CONVENTIONS.md)。
4
+ *
5
+ * 场景照 QQ 宠物的台词分类来(enter / eat / clean / toHeartTolk / levUp /
6
+ * 生病 tolk / errTolk / successTolk ……)。`[主人]` 会换成主人的称呼。
7
+ * 文案来自 docs/tasks/numbers/B6-lines.md(用户 2026-10-01:按稿直接上)。
8
+ *
9
+ * @module dsh-piggy/data/lines
10
+ */
11
+
12
+ /** 台词里代表主人称呼的占位符。 */
13
+ export const OWNER_TOKEN = '[主人]'
14
+
15
+ /** 没设置称呼时用的默认称呼。 */
16
+ export const DEFAULT_OWNER_NAME = '主人'
17
+
18
+ /** 称呼最多几个字。 */
19
+ export const OWNER_NAME_MAX = 12
20
+
21
+ /** 点一次回复按钮加的心情;每句台词只算一次。 */
22
+ export const REPLY_HAPPINESS = 3
23
+
24
+ /** 闲着时隔多久冒一句(分钟,区间内随机)。 */
25
+ export const IDLE_CHAT_MINUTES = Object.freeze({ min: 20, max: 40 })
26
+
27
+ /** 离开多久再打开算「你回来了」(分钟)。 */
28
+ export const WELCOME_BACK_AFTER_MINUTES = 30
29
+
30
+ /**
31
+ * @typedef {object} LineReply
32
+ * @property {string} label - 按钮上的字
33
+ * @property {number} [happiness] - 点了加多少心情,默认 REPLY_HAPPINESS
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} Line
38
+ * @property {string} text
39
+ * @property {ReadonlyArray<LineReply>} [replies]
40
+ */
41
+
42
+ /**
43
+ * One line, with an optional reply button.
44
+ * @param {string} text
45
+ * @param {string} [reply]
46
+ * @returns {Line}
47
+ */
48
+ const line = (text, reply) => Object.freeze(reply === undefined
49
+ ? { text }
50
+ : { text, replies: Object.freeze([Object.freeze({ label: reply })]) })
51
+
52
+ const scene = (...lines) => Object.freeze(lines)
53
+
54
+ /** @type {Readonly<Record<string, ReadonlyArray<Line>>>} */
55
+ export const LINES = Object.freeze({
56
+ // --- 照顾 -----------------------------------------------------------------
57
+ eat: scene(
58
+ line('好吃!还有吗?', '真乖'),
59
+ line('吧唧吧唧……'),
60
+ line('[主人]最好了~'),
61
+ line('这个味道我记住了'),
62
+ line('吃饱饱才有力气陪你加班'),
63
+ line('嗝——(不好意思)'),
64
+ ),
65
+ overfull: scene(
66
+ line('撑……撑住了……'),
67
+ line('真的吃不下了,你看我肚子'),
68
+ line('再喂我就要变成球了', '最后一口'),
69
+ ),
70
+ bathe: scene(
71
+ line('香喷喷的!'),
72
+ line('水有点凉……', '马上擦干'),
73
+ line('搓搓背,舒服~'),
74
+ line('泡泡!是泡泡!'),
75
+ line('洗干净了,可以抱了'),
76
+ ),
77
+ play: scene(
78
+ line('再来一次!'),
79
+ line('接住啦!', '真棒'),
80
+ line('哈哈哈好好玩'),
81
+ line('我跑得比球快'),
82
+ line('玩累了……再玩五分钟'),
83
+ ),
84
+ pet: scene(
85
+ line('好舒服……'),
86
+ line('再摸摸~', '好'),
87
+ line('呼噜呼噜……'),
88
+ line('(眯起眼睛)'),
89
+ line('这里这里!左边一点!'),
90
+ line('唔……好痒'),
91
+ line('[主人]的手暖暖的'),
92
+ ),
93
+ // --- 状态提醒(闲着时优先说这些)-------------------------------------------
94
+ hungry: scene(
95
+ line('肚子咕咕叫了……'),
96
+ line('[主人],饭饭!', '马上来'),
97
+ line('我可以吃一整个苹果树'),
98
+ ),
99
+ dirty: scene(
100
+ line('身上有点痒痒的'),
101
+ line('我是不是有点味道了……'),
102
+ line('想洗泡泡浴', '好,这就洗'),
103
+ ),
104
+ lonely: scene(
105
+ line('[主人]在忙什么呀?'),
106
+ line('你好久没理我了……', '陪你一会儿'),
107
+ line('我一个人在这儿数像素'),
108
+ ),
109
+ idle: scene(
110
+ line('(打了个哈欠)'),
111
+ line('今天天气好像不错'),
112
+ line('你写的代码我看懂了一行!'),
113
+ line('要不要休息一下眼睛?', '好'),
114
+ line('我在想晚饭吃什么'),
115
+ line('(在角落里滚了一圈)'),
116
+ line('[主人]加油,我在旁边看着'),
117
+ line('刚才那个报错我也看见了……'),
118
+ ),
119
+ // --- 出门 -----------------------------------------------------------------
120
+ workDone: scene(
121
+ line('我回来啦!赚到钱了!', '辛苦了'),
122
+ line('今天老板夸我了'),
123
+ line('累是累了点,但是有钱了'),
124
+ ),
125
+ tired: scene(
126
+ line('好累啊……', '歇会儿吧'),
127
+ line('能不能先让我躺一下'),
128
+ line('再干下去我要头晕了'),
129
+ ),
130
+ study: scene(
131
+ line('今天学到好多!', '真乖'),
132
+ line('老师讲的我都听懂了(大概)'),
133
+ line('作业……明天再说'),
134
+ ),
135
+ graduate: scene(
136
+ line('我毕业啦!我没有留级!', '真棒'),
137
+ line('看,我的毕业照!'),
138
+ line('下一段我也能念完'),
139
+ ),
140
+ tripBack: scene(
141
+ line('我给你带了东西!', '是什么?'),
142
+ line('外面好大啊'),
143
+ line('下次带你一起去'),
144
+ ),
145
+ // --- 生病 -----------------------------------------------------------------
146
+ sick: scene(
147
+ line('阿——嚏![主人],我好像病了……', '乖,吃药'),
148
+ line('头有点晕晕的'),
149
+ line('我不想动……'),
150
+ ),
151
+ wrongMedicine: scene(
152
+ line('这药好苦……好像不是这个', '对不起'),
153
+ line('呜,更难受了'),
154
+ line('[主人]你是不是看错说明书了'),
155
+ ),
156
+ cured: scene(
157
+ line('我好啦!谢谢[主人]~', '真乖'),
158
+ line('又能跑能跳了!'),
159
+ line('以后我会乖乖吃饭的'),
160
+ ),
161
+ // --- 成长与生死 -------------------------------------------------------------
162
+ levelup: scene(
163
+ line('我又长大了一点!', '真乖'),
164
+ line('感觉自己变厉害了'),
165
+ line('你看我是不是高了一点'),
166
+ ),
167
+ growUp: scene(
168
+ line('我长大啦!'),
169
+ line('以前的衣服好像穿不下了'),
170
+ line('[主人],我现在是大猪了'),
171
+ ),
172
+ enter: scene(
173
+ line('[主人]你回来啦!', '回来了'),
174
+ line('等你好久了~'),
175
+ line('今天也要一起加油哦'),
176
+ ),
177
+ death: scene(
178
+ line('[主人]保重,我走了,不带走一片云彩~'),
179
+ line('下辈子还给你当猪'),
180
+ ),
181
+ revive: scene(
182
+ line('我……我回来了?'),
183
+ line('那边好冷,还是这里好'),
184
+ line('谢谢你没放弃我', '欢迎回来'),
185
+ ),
186
+ // --- B5 用 ------------------------------------------------------------------
187
+ signIn: scene(
188
+ line('签到啦!今天也要好好的'),
189
+ line('这是今天的礼物,给你~'),
190
+ ),
191
+ gift: scene(
192
+ line('我在地上捡到一个盒子!'),
193
+ line('陪你这么久,这是奖励'),
194
+ ),
195
+ })
196
+
197
+ /** Every scene a line can be asked for. */
198
+ export const LINE_SCENES = Object.freeze(Object.keys(LINES))