dsh-piggy 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1348 -0
- package/LICENSE +32 -0
- package/README.md +322 -0
- package/THIRD-PARTY.md +31 -0
- package/assets/elder.svg +35 -0
- package/assets/pig-king-bathe.svg +38 -0
- package/assets/pig-king-eat.svg +45 -0
- package/assets/pig-king-pet.svg +45 -0
- package/assets/pig-king-play.svg +45 -0
- package/assets/pig-king-relaxed.svg +56 -0
- package/assets/pig-king-study.svg +45 -0
- package/assets/pig-king-trip.svg +45 -0
- package/assets/pig-king-work.svg +45 -0
- package/assets/pig-king.svg +56 -0
- package/assets/piglet.svg +30 -0
- package/client.js +3438 -0
- package/commands.js +252 -0
- package/cordis.patch.yml +15 -0
- package/core.js +11 -0
- package/data.js +10 -0
- package/index.js +68 -0
- package/package.json +72 -0
- package/packages/pet-core/package.json +21 -0
- package/packages/pet-core/src/core/activity.js +80 -0
- package/packages/pet-core/src/core/care.js +160 -0
- package/packages/pet-core/src/core/clock.js +122 -0
- package/packages/pet-core/src/core/constants.js +109 -0
- package/packages/pet-core/src/core/daily.js +204 -0
- package/packages/pet-core/src/core/diary.js +108 -0
- package/packages/pet-core/src/core/drops.js +82 -0
- package/packages/pet-core/src/core/effects.js +84 -0
- package/packages/pet-core/src/core/egg.js +120 -0
- package/packages/pet-core/src/core/evolution.js +90 -0
- package/packages/pet-core/src/core/growth.js +110 -0
- package/packages/pet-core/src/core/illness.js +235 -0
- package/packages/pet-core/src/core/interests.js +55 -0
- package/packages/pet-core/src/core/inventory.js +177 -0
- package/packages/pet-core/src/core/lines.js +157 -0
- package/packages/pet-core/src/core/migrate.js +226 -0
- package/packages/pet-core/src/core/profile.js +102 -0
- package/packages/pet-core/src/core/random.js +79 -0
- package/packages/pet-core/src/core/school.js +81 -0
- package/packages/pet-core/src/core/settlement.js +308 -0
- package/packages/pet-core/src/core/state.js +210 -0
- package/packages/pet-core/src/core/travel.js +58 -0
- package/packages/pet-core/src/core/upgrades.js +193 -0
- package/packages/pet-core/src/core/views.js +55 -0
- package/packages/pet-core/src/core/work.js +50 -0
- package/packages/pet-core/src/core.js +95 -0
- package/packages/pet-core/src/data/daily.js +121 -0
- package/packages/pet-core/src/data/drops.js +34 -0
- package/packages/pet-core/src/data/evolution.js +41 -0
- package/packages/pet-core/src/data/growth.js +72 -0
- package/packages/pet-core/src/data/illness.js +188 -0
- package/packages/pet-core/src/data/interests.js +39 -0
- package/packages/pet-core/src/data/jobs.js +150 -0
- package/packages/pet-core/src/data/life.js +88 -0
- package/packages/pet-core/src/data/lines.js +198 -0
- package/packages/pet-core/src/data/minutes.js +23 -0
- package/packages/pet-core/src/data/profile.js +60 -0
- package/packages/pet-core/src/data/school.js +99 -0
- package/packages/pet-core/src/data/shop.js +142 -0
- package/packages/pet-core/src/data/traits.js +42 -0
- package/packages/pet-core/src/data/travel.js +87 -0
- package/packages/pet-core/src/data.js +52 -0
- package/packages/pet-core/src/index.js +12 -0
- package/render.js +357 -0
- package/routes.js +201 -0
- package/scripts/build-client.mjs +100 -0
- package/scripts/typecheck.mjs +122 -0
- package/snapshot.js +368 -0
- package/src/client/art.js +29 -0
- package/src/client/constants.js +88 -0
- package/src/client/css-base.js +225 -0
- package/src/client/css-card.js +63 -0
- package/src/client/css-tabs.js +250 -0
- package/src/client/css-tiles.js +89 -0
- package/src/client/dom.js +33 -0
- package/src/client/effects.js +159 -0
- package/src/client/format.js +27 -0
- package/src/client/index.js +393 -0
- package/src/client/io.js +100 -0
- package/src/client/layout.js +103 -0
- package/src/client/normalize.js +352 -0
- package/src/client/panel.js +319 -0
- package/src/client/scene.js +113 -0
- package/src/client/storage.js +19 -0
- package/src/client/styles.js +13 -0
- package/src/client/tabs/bag.js +203 -0
- package/src/client/tabs/card.js +110 -0
- package/src/client/tabs/crown.js +59 -0
- package/src/client/tabs/dev.js +101 -0
- package/src/client/tabs/home.js +72 -0
- package/src/client/tabs/shop.js +78 -0
- package/src/client/tabs/status.js +223 -0
- package/src/client/tabs/study.js +128 -0
- package/src/client/tabs/travel.js +89 -0
- package/src/client/tabs/update.js +164 -0
- package/src/client/tabs/work.js +106 -0
- package/src/client/values.js +11 -0
- package/src/client/widgets.js +126 -0
- package/store/api.js +260 -0
- package/store/state-file.js +129 -0
- package/store.js +96 -0
|
@@ -0,0 +1,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
|
+
}
|