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,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)
|