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,226 @@
1
+ // @ts-check
2
+ /**
3
+ * 存档迁移与字段清洗。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/migrate
7
+ */
8
+
9
+ import { ILLNESS_CHAINS, INTERESTS, formByKey, MAX, SHOP, SOUVENIR_RARITY, TRAIT_ORDER, interestByKey, itemByKey, jobByKey, schoolStageByKey, subjectByKey, tripByKey } from '../data.js'
10
+ import { MEMORY_LIMIT, STATE_VERSION } from './constants.js'
11
+ import { clamp, clamp100 } from './effects.js'
12
+ import { layEgg, pickSex } from './egg.js'
13
+ import { ensureDaily } from './daily.js'
14
+ import { ensureDiary } from './diary.js'
15
+ import { ensureDialogue } from './lines.js'
16
+ import { ensureProfile } from './profile.js'
17
+ import { isSeed, seedFor } from './random.js'
18
+ import { applyUpgrades } from './upgrades.js'
19
+
20
+ /** Fill in anything a hand-edited or older save is missing. */
21
+ export function migrate(input, nowMs) {
22
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return null
23
+ const onDiskVersion = typeof input.version === 'number' ? input.version : 0
24
+ // Untrusted save data: every field below is checked before it is used.
25
+ const raw = /** @type {any} */ (applyUpgrades(input, nowMs))
26
+ const egg = layEgg(typeof raw.bornAt === 'number' ? raw.bornAt : nowMs)
27
+ const state = { ...egg, ...raw }
28
+ state.version = STATE_VERSION
29
+ state.form = formByKey(raw.form)?.key ?? null
30
+ state.stats = { ...egg.stats, ...(asObject(raw.stats) ?? {}) }
31
+ state.stats.jobs = Number.isFinite(state.stats.jobs) ? Math.max(0, Math.floor(state.stats.jobs)) : 0
32
+ state.cooldowns = { ...(asObject(raw.cooldowns) ?? {}) }
33
+ state.inventory = sanitizeInventory(raw.inventory)
34
+ state.dress = sanitizeDressList(raw.dress)
35
+ // Only owned items can be worn, and unknown keys are dropped.
36
+ state.worn = sanitizeDressList(raw.worn).filter(key => state.dress.includes(key))
37
+ state.traits = sanitizeTraits(raw.traits)
38
+ state.lessons = sanitizeLessons(raw.lessons)
39
+ state.interests = sanitizeInterests(raw.interests)
40
+ state.souvenirs = sanitizeSouvenirs(raw.souvenirs)
41
+ state.pending = []
42
+ state.memories = Array.isArray(raw.memories)
43
+ ? raw.memories.filter(m => typeof m === 'string').slice(-MEMORY_LIMIT)
44
+ : []
45
+
46
+ for (const key of ['xp', 'weightG', 'satiety', 'happiness', 'cleanliness', 'health', 'coins', 'outingStreak', 'restMinutes', 'bornAt', 'lastFedAt', 'lastActiveAt', 'lastSeenAt']) {
47
+ if (typeof state[key] !== 'number' || !Number.isFinite(state[key])) state[key] = egg[key]
48
+ }
49
+ if (typeof raw.cleanliness !== 'number') state.cleanliness = egg.cleanliness
50
+ if (typeof raw.health !== 'number') state.health = raw.dead === true ? 0 : MAX.health
51
+ if (typeof raw.coins !== 'number') state.coins = egg.coins
52
+ // Starting money went 60 -> 500. A pig that hatched under the old number is
53
+ // broke through no fault of its owner, so top it up once — and only once, by
54
+ // keying off the version that was on disk when it was loaded.
55
+ if (onDiskVersion < 5 && state.coins >= 0 && state.coins < egg.coins) {
56
+ state.coins = egg.coins
57
+ }
58
+ if (typeof raw.diedAt !== 'number') state.diedAt = state.dead === true ? (raw.lastSeenAt ?? egg.bornAt) : null
59
+ if (typeof state.stage !== 'string') state.stage = state.hatched === true ? 'piglet' : 'box'
60
+ if (typeof state.name !== 'string' || state.name.trim() === '') state.name = egg.name
61
+
62
+ state.health = clamp(Math.round(state.health), 0, MAX.health)
63
+ state.satiety = clamp100(state.satiety)
64
+ state.happiness = clamp100(state.happiness)
65
+ state.cleanliness = clamp100(state.cleanliness)
66
+ state.coins = Math.max(0, Math.floor(state.coins))
67
+ state.illness = sanitizeIllness(raw.illness, nowMs)
68
+ state.activity = sanitizeActivity(raw.activity ?? raw.work)
69
+ state.dead = state.dead === true || state.health <= 0
70
+ state.hatched = state.hatched === true
71
+ if (!isSeed(state.seed)) state.seed = seedFor(state)
72
+ if (state.hatched && state.sex !== 'boy' && state.sex !== 'girl') state.sex = pickSex(state)
73
+ if (state.stage === 'elder') state.stage = 'middle'
74
+ if (!Number.isInteger(state.pendingSeq) || state.pendingSeq < 0) state.pendingSeq = 0
75
+ ensureDialogue(state)
76
+ // B9: personality, catchphrase and motto — filled in for older pigs on load.
77
+ ensureProfile(state)
78
+ ensureDaily(state)
79
+ ensureDiary(state)
80
+ return state
81
+ }
82
+
83
+ export function asObject(value) {
84
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null
85
+ }
86
+
87
+ export function sanitizeInventory(raw) {
88
+ const source = asObject(raw)
89
+ if (source === null) return {}
90
+ const out = {}
91
+ for (const [key, count] of Object.entries(source)) {
92
+ if (!Number.isFinite(count)) continue
93
+ const n = Math.floor(count)
94
+ if (n > 0 && SHOP.some(item => item.key === key)) out[key] = n
95
+ }
96
+ return out
97
+ }
98
+
99
+ export function sanitizeTraits(raw) {
100
+ const source = asObject(raw)
101
+ const out = {}
102
+ for (const key of TRAIT_ORDER) {
103
+ const value = source?.[key]
104
+ out[key] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
105
+ }
106
+ return out
107
+ }
108
+
109
+ /** Lessons taken per subject (B4): known subject keys, whole positive counts. */
110
+ export function sanitizeLessons(raw) {
111
+ const source = asObject(raw)
112
+ if (source === null) return {}
113
+ const out = {}
114
+ for (const [key, count] of Object.entries(source)) {
115
+ if (!Number.isFinite(count)) continue
116
+ const n = Math.floor(count)
117
+ if (n > 0 && subjectByKey(key) !== null) out[key] = n
118
+ }
119
+ return out
120
+ }
121
+
122
+ /**
123
+ * Souvenirs became objects in 0.20.0 so they could carry a rarity and a story.
124
+ * A save written before that holds bare strings ("贝壳"); wrap them so an old
125
+ * collection stays visible and sellable instead of being dropped.
126
+ */
127
+ export function sanitizeSouvenirs(raw) {
128
+ if (!Array.isArray(raw)) return []
129
+ // No cap: the shelf used to keep only the last 40, which threw away the
130
+ // oldest keepsakes (and made them unsellable) without telling anyone.
131
+ const out = []
132
+ for (const entry of raw) {
133
+ if (typeof entry === 'string' && entry !== '') {
134
+ out.push({ key: entry, emoji: '🎁', label: entry, rarity: 'common', story: '', from: null, fromLabel: '' })
135
+ continue
136
+ }
137
+ const source = asObject(entry)
138
+ if (source === null || typeof source.key !== 'string' || source.key === '') continue
139
+ out.push({
140
+ key: source.key,
141
+ emoji: typeof source.emoji === 'string' && source.emoji !== '' ? source.emoji : '🎁',
142
+ label: typeof source.label === 'string' && source.label !== '' ? source.label : source.key,
143
+ rarity: typeof source.rarity === 'string' && SOUVENIR_RARITY[source.rarity] !== undefined ? source.rarity : 'common',
144
+ story: typeof source.story === 'string' ? source.story : '',
145
+ from: typeof source.from === 'string' ? source.from : null,
146
+ fromLabel: typeof source.fromLabel === 'string' ? source.fromLabel : '',
147
+ })
148
+ }
149
+ return out
150
+ }
151
+
152
+ /**
153
+ * Owned / worn 装扮 keys. Unknown and duplicate keys are dropped, and only real
154
+ * dress items survive, so a hand-edited save cannot dress the pig in a 药品.
155
+ */
156
+ export function sanitizeDressList(raw) {
157
+ if (!Array.isArray(raw)) return []
158
+ const out = []
159
+ for (const key of raw) {
160
+ if (typeof key !== 'string') continue
161
+ const item = itemByKey(key)
162
+ if (item === null || item.kind !== 'dress' || out.includes(key)) continue
163
+ out.push(key)
164
+ }
165
+ return out
166
+ }
167
+
168
+ /** How many times each 兴趣课 has been taken; unknown keys are dropped. */
169
+ export function sanitizeInterests(raw) {
170
+ const source = asObject(raw)
171
+ if (source === null) return {}
172
+ const out = {}
173
+ for (const entry of INTERESTS) {
174
+ const value = source[entry.key]
175
+ if (Number.isFinite(value) && value > 0) out[entry.key] = Math.floor(value)
176
+ }
177
+ return out
178
+ }
179
+
180
+ export function sanitizeIllness(raw, nowMs) {
181
+ const source = asObject(raw)
182
+ if (source === null) return null
183
+ const chain = Number.isInteger(source.chain) ? source.chain : -1
184
+ const stage = Number.isInteger(source.stage) ? source.stage : 0
185
+ if (chain < 0 || chain >= ILLNESS_CHAINS.length) return null
186
+ if (stage < 1 || stage > ILLNESS_CHAINS[chain].stages.length) return null
187
+ return {
188
+ chain,
189
+ stage,
190
+ since: Number.isFinite(source.since) ? source.since : nowMs,
191
+ progressMs: Number.isFinite(source.progressMs) ? Math.max(0, source.progressMs) : 0,
192
+ }
193
+ }
194
+
195
+ /** Accepts both the v4 `activity` record and the v3 `work` record. */
196
+ export function sanitizeActivity(raw) {
197
+ const source = asObject(raw)
198
+ if (source === null) return null
199
+ if (!Number.isFinite(source.endsAt)) return null
200
+ const kind = source.kind ?? 'work'
201
+ if (!['work', 'study', 'trip', 'interest'].includes(kind)) return null
202
+ // A shift from an older job table survives if the upgrade priced it.
203
+ const known = kind === 'work'
204
+ ? jobByKey(source.key ?? source.job) !== null || Number.isFinite(source.legacyCoins)
205
+ : kind === 'study'
206
+ ? subjectByKey(source.key) !== null
207
+ : kind === 'interest'
208
+ ? interestByKey(source.key) !== null
209
+ : tripByKey(source.key) !== null
210
+ if (!known) return null
211
+ return {
212
+ kind,
213
+ key: source.key ?? source.job,
214
+ stage: kind === 'study' && schoolStageByKey(source.stage) !== null ? source.stage : undefined,
215
+ label: typeof source.label === 'string' ? source.label : '',
216
+ emoji: typeof source.emoji === 'string' ? source.emoji : '',
217
+ startedAt: Number(source.startedAt) || 0,
218
+ endsAt: source.endsAt,
219
+ cost: Number(source.cost) || 0,
220
+ ...(Number.isFinite(source.legacyCoins) ? { legacyCoins: source.legacyCoins, minutes: Number(source.minutes) || 0 } : {}),
221
+ }
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Small shared helpers
226
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,102 @@
1
+ // @ts-check
2
+ /**
3
+ * 居民卡:性格、口头禅、签名、生日星座(B9)。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,随机数来自 core/random.js(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/profile
7
+ */
8
+
9
+ import { CATCHPHRASE_MAX, GRADUATION_LESSONS, MOTTO_MAX, PERSONALITIES, ZODIAC, personalityByKey } from '../data.js'
10
+ import { CERTIFICATE_AFTER } from '../data.js'
11
+ import { ageDays } from './clock.js'
12
+ import { pickOne, rollerFor } from './random.js'
13
+
14
+ /**
15
+ * Give a newly hatched pig its personality, and the catchphrase and motto that
16
+ * come with it (the owner can change both later).
17
+ * @param {object} state
18
+ */
19
+ export function assignPersonality(state) {
20
+ const personality = pickOne(rollerFor(state), PERSONALITIES) ?? PERSONALITIES[0]
21
+ state.personality = personality.key
22
+ state.catchphrase = personality.catchphrase
23
+ state.motto = personality.motto
24
+ }
25
+
26
+ /** Make sure a hatched pig has every profile field (older saves, hand edits). */
27
+ export function ensureProfile(state) {
28
+ if (state.hatched !== true) return
29
+ if (personalityByKey(state.personality) === null) {
30
+ assignPersonality(state)
31
+ return
32
+ }
33
+ const personality = personalityByKey(state.personality)
34
+ if (typeof state.catchphrase !== 'string' || state.catchphrase.trim() === '') state.catchphrase = personality?.catchphrase ?? ''
35
+ if (typeof state.motto !== 'string' || state.motto.trim() === '') state.motto = personality?.motto ?? ''
36
+ }
37
+
38
+ /** Trim, cut to `max` characters (not UTF-16 units), refuse empty. */
39
+ function cleanText(raw, max) {
40
+ if (typeof raw !== 'string') return ''
41
+ return Array.from(raw.trim()).slice(0, max).join('')
42
+ }
43
+
44
+ /** The owner changes the pig's catchphrase. */
45
+ export function setCatchphrase(state, raw) {
46
+ if (state === null) return { ok: false, reason: 'absent' }
47
+ const text = cleanText(raw, CATCHPHRASE_MAX)
48
+ if (text === '') return { ok: false, reason: 'empty' }
49
+ state.catchphrase = text
50
+ return { ok: true, catchphrase: text }
51
+ }
52
+
53
+ /** The owner changes the line on the pig's card. */
54
+ export function setMotto(state, raw) {
55
+ if (state === null) return { ok: false, reason: 'absent' }
56
+ const text = cleanText(raw, MOTTO_MAX)
57
+ if (text === '') return { ok: false, reason: 'empty' }
58
+ state.motto = text
59
+ return { ok: true, motto: text }
60
+ }
61
+
62
+ /**
63
+ * The star sign for a month (1–12) and day.
64
+ * @returns {{label: string, emoji: string}}
65
+ */
66
+ export function zodiacFor(month, day) {
67
+ let found = ZODIAC[ZODIAC.length - 1]
68
+ for (const sign of ZODIAC) {
69
+ if (month > sign.month || (month === sign.month && day >= sign.day)) found = sign
70
+ }
71
+ return { label: found.label, emoji: found.emoji }
72
+ }
73
+
74
+ /**
75
+ * Everything the villager card shows, worked out once on the host.
76
+ * @param {object} state
77
+ * @param {number} nowMs
78
+ */
79
+ export function profileView(state, nowMs) {
80
+ if (state === null || state.hatched !== true) return null
81
+ const personality = personalityByKey(state.personality)
82
+ const born = new Date(typeof state.bornAt === 'number' ? state.bornAt : nowMs)
83
+ const month = born.getMonth() + 1
84
+ const day = born.getDate()
85
+ const lessons = state.lessons ?? {}
86
+ const graduated = Object.values(lessons).reduce(
87
+ (sum, count) => sum + GRADUATION_LESSONS.filter(mark => count >= mark).length, 0)
88
+ const certificates = Object.values(state.interests ?? {}).filter(count => count >= CERTIFICATE_AFTER).length
89
+ return {
90
+ personality: personality === null ? null : { key: personality.key, label: personality.label, emoji: personality.emoji },
91
+ catchphrase: typeof state.catchphrase === 'string' ? state.catchphrase : '',
92
+ motto: typeof state.motto === 'string' ? state.motto : '',
93
+ birthday: `${month} 月 ${day} 日`,
94
+ zodiac: zodiacFor(month, day),
95
+ counts: {
96
+ days: Math.floor(ageDays(state, nowMs)),
97
+ certificates,
98
+ souvenirs: Array.isArray(state.souvenirs) ? state.souvenirs.length : 0,
99
+ graduations: graduated,
100
+ },
101
+ }
102
+ }
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+ /**
3
+ * 随机数:种子存在存档里,领域层自己推进。
4
+ *
5
+ * 领域层不许调用 Math.random()(见 test/core-boundary.test.js)。可随机的玩法
6
+ * (病自愈、以后的发病、掉落、礼包)一律从这里取数:种子是 `state.seed`,每取一次
7
+ * 就往前推一步,所以同一份存档加同样的操作,结果永远一样 —— 可复现、可测试,
8
+ * 也不需要把随机源一路穿过每个公开函数的签名。
9
+ *
10
+ * 需要指定结果的调用方(测试、调试页)可以直接给 `decay()` 传 `roll`。
11
+ *
12
+ * @module dsh-piggy/core/random
13
+ */
14
+
15
+ /** @typedef {() => number} Roll 返回 [0, 1) 的一个数 */
16
+
17
+ const UINT32 = 0x1_0000_0000
18
+
19
+ /**
20
+ * A seed for a pig that has none yet (fresh egg, older save).
21
+ *
22
+ * Derived from its birthday and name, so two different pigs start on different
23
+ * sequences without the library having to read a clock or an entropy source.
24
+ * @param {{bornAt?: unknown, name?: unknown}} state
25
+ * @returns {number}
26
+ */
27
+ export function seedFor(state) {
28
+ let hash = 0x811c9dc5
29
+ const text = `${typeof state.bornAt === 'number' ? state.bornAt : 0}|${typeof state.name === 'string' ? state.name : ''}`
30
+ for (let index = 0; index < text.length; index += 1) {
31
+ hash ^= text.charCodeAt(index)
32
+ hash = Math.imul(hash, 0x01000193) >>> 0
33
+ }
34
+ return hash >>> 0
35
+ }
36
+
37
+ /** Whether `value` is a usable stored seed (an unsigned 32-bit integer). */
38
+ export const isSeed = value => Number.isInteger(value) && value >= 0 && value < UINT32
39
+
40
+ /**
41
+ * Draw the next number in [0, 1) from the pig's own sequence (mulberry32).
42
+ * @param {{seed?: unknown, bornAt?: unknown, name?: unknown}} state - `seed` is advanced in place.
43
+ * @returns {number}
44
+ */
45
+ export function roll(state) {
46
+ const current = isSeed(state.seed) ? /** @type {number} */ (state.seed) : seedFor(state)
47
+ const next = (current + 0x6d2b79f5) >>> 0
48
+ state.seed = next
49
+ let mixed = next
50
+ mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)
51
+ mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)
52
+ return ((mixed ^ (mixed >>> 14)) >>> 0) / UINT32
53
+ }
54
+
55
+ /**
56
+ * A `Roll` bound to one pig — what the settlement loop hands down.
57
+ * @param {object} state
58
+ * @returns {Roll}
59
+ */
60
+ export const rollerFor = state => () => roll(state)
61
+
62
+ /**
63
+ * True with probability `chance`.
64
+ * @param {Roll} next
65
+ * @param {number} chance - 0..1; anything outside is clamped.
66
+ */
67
+ export const chance = (next, chance) => chance > 0 && next() < Math.min(1, chance)
68
+
69
+ /**
70
+ * One element of `list`, or null when it is empty.
71
+ * @template T
72
+ * @param {Roll} next
73
+ * @param {ReadonlyArray<T>} list
74
+ * @returns {T|null}
75
+ */
76
+ export function pickOne(next, list) {
77
+ if (list.length === 0) return null
78
+ return list[Math.min(list.length - 1, Math.floor(next() * list.length))]
79
+ }
@@ -0,0 +1,81 @@
1
+ // @ts-check
2
+ /**
3
+ * 九门课:每门课各算各的课时,课时决定这门课在哪个学段、能干什么工作(B4)。
4
+ *
5
+ * 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/core/school
7
+ */
8
+
9
+ import { GRADUATION_LESSONS, SUBJECTS, TRAITS, graduatedStage, stageForNextLesson, subjectByKey } 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
+ /** Lessons taken per subject, always including every subject (0 = never). */
15
+ export function courseView(state) {
16
+ const out = {}
17
+ for (const subject of SUBJECTS) out[subject.key] = state.lessons?.[subject.key] ?? 0
18
+ return out
19
+ }
20
+
21
+ /**
22
+ * Each subject as the panel needs it: where it is, what the next lesson costs
23
+ * and gives, and how far to the next graduation.
24
+ */
25
+ export function studyView(state) {
26
+ const taken = courseView(state)
27
+ return SUBJECTS.map(subject => {
28
+ const count = taken[subject.key]
29
+ const next = stageForNextLesson(count)
30
+ const done = graduatedStage(count)
31
+ const nextGraduation = GRADUATION_LESSONS.find(mark => mark > count) ?? null
32
+ return {
33
+ key: subject.key,
34
+ label: subject.label,
35
+ emoji: subject.emoji,
36
+ trait: subject.trait,
37
+ traitLabel: TRAITS[subject.trait].label,
38
+ traitEmoji: TRAITS[subject.trait].emoji,
39
+ secondary: subject.secondary,
40
+ lessons: count,
41
+ stage: { key: next.key, label: next.label, emoji: next.emoji },
42
+ graduated: done === null ? null : { key: done.key, label: done.label },
43
+ nextGraduation,
44
+ minutes: next.minutes,
45
+ tuition: next.tuition,
46
+ gain: next.gain,
47
+ secondaryGain: subject.secondary === null ? 0 : next.secondaryGain,
48
+ }
49
+ })
50
+ }
51
+
52
+ /**
53
+ * Send the pig to the next lesson of a subject. The stage is decided by how
54
+ * many lessons of it the pig has already taken; `_stageKey` is accepted for
55
+ * older callers and ignored.
56
+ */
57
+ export function startStudy(state, subjectKey, _stageKey, nowMs) {
58
+ if (state.hatched !== true) return { ok: false, reason: 'box' }
59
+ const subject = subjectByKey(subjectKey)
60
+ if (subject === null) return { ok: false, reason: 'unknown' }
61
+ if (state.dead) return { ok: false, reason: 'dead' }
62
+ if (state.activity !== null) return { ok: false, reason: 'away' }
63
+ if (state.health <= TOO_WEAK_HEALTH) return { ok: false, reason: 'weak' }
64
+ const taken = state.lessons?.[subject.key] ?? 0
65
+ const stage = stageForNextLesson(taken)
66
+ if (state.coins < stage.tuition) return { ok: false, reason: 'poor', price: stage.tuition }
67
+ if (state.satiety < 15) return { ok: false, reason: 'hungry' }
68
+
69
+ state.coins -= stage.tuition
70
+ const result = begin(state, {
71
+ kind: 'study', key: subject.key, stage: stage.key,
72
+ label: `${subject.label}(${stage.label}第 ${taken + 1} 节)`, emoji: subject.emoji,
73
+ minutes: stage.minutes, cost: stage.tuition,
74
+ }, nowMs)
75
+ if (!result.ok) {
76
+ state.coins += stage.tuition // refund if the pig turned out to be unavailable
77
+ return result
78
+ }
79
+ remember(state, `${subject.emoji} 去上${subject.label}第 ${taken + 1} 节(学费 ${stage.tuition})`, nowMs)
80
+ return result
81
+ }