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,160 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 照护动作(喂食/洗澡/玩耍)。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/care
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CARE_KIND, ILLNESS_ONSET, careItems } from '../data.js'
|
|
10
|
+
import { ACTIONS, DIET } from './constants.js'
|
|
11
|
+
import { applyEffects, remember } from './effects.js'
|
|
12
|
+
import { growFromRealWork } from './growth.js'
|
|
13
|
+
import { rollForOverfeeding } from './illness.js'
|
|
14
|
+
import { say } from './lines.js'
|
|
15
|
+
import { decay } from './settlement.js'
|
|
16
|
+
import { noteToday } from './diary.js'
|
|
17
|
+
|
|
18
|
+
/** Which line scene each care action makes the pig speak from. */
|
|
19
|
+
const CARE_SCENE = Object.freeze({ feed: 'eat', bathe: 'bathe', play: 'play', pet: 'pet' })
|
|
20
|
+
|
|
21
|
+
/** Which shelves the pig can actually use right now, for the panel's picker. */
|
|
22
|
+
export function careView(state) {
|
|
23
|
+
const out = {}
|
|
24
|
+
for (const action of ['feed', 'bathe', 'play']) out[action] = careOptions(state, action)
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function feed(state, event, nowMs) {
|
|
29
|
+
const diet = DIET[event]
|
|
30
|
+
if (diet === undefined) return []
|
|
31
|
+
decay(state, nowMs)
|
|
32
|
+
if (state.dead) return []
|
|
33
|
+
switch (event) {
|
|
34
|
+
case 'message': state.stats.messages += 1; break
|
|
35
|
+
case 'turn': state.stats.turns += 1; break
|
|
36
|
+
case 'tool': state.stats.tools += 1; break
|
|
37
|
+
case 'toolError': state.stats.toolErrors += 1; break
|
|
38
|
+
case 'agentError': state.stats.agentErrors += 1; break
|
|
39
|
+
default: break
|
|
40
|
+
}
|
|
41
|
+
// The box does not eat: the work still counts in the stats, nothing else.
|
|
42
|
+
if (state.hatched !== true) return []
|
|
43
|
+
applyEffects(state, diet, nowMs)
|
|
44
|
+
growFromRealWork(state, event, nowMs)
|
|
45
|
+
noteToday(state, event)
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Care actions
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export function actionCooldownSeconds(state, action, nowMs) {
|
|
54
|
+
const spec = ACTIONS[action]
|
|
55
|
+
if (spec === undefined || spec.cooldownMs <= 0) return 0
|
|
56
|
+
const last = state.cooldowns?.[action] ?? 0
|
|
57
|
+
const remaining = spec.cooldownMs - (nowMs - last)
|
|
58
|
+
return remaining <= 0 ? 0 : Math.ceil(remaining / 1000)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const actionReady = (state, action, nowMs) => actionCooldownSeconds(state, action, nowMs) === 0
|
|
62
|
+
|
|
63
|
+
export function act(state, action, nowMs, itemKey) {
|
|
64
|
+
const spec = ACTIONS[action]
|
|
65
|
+
if (spec === undefined) return { ok: false, reason: 'unknown' }
|
|
66
|
+
if (state === null) return { ok: false, reason: 'absent' }
|
|
67
|
+
if (state.hatched !== true) return { ok: false, reason: 'box' }
|
|
68
|
+
decay(state, nowMs)
|
|
69
|
+
if (state.dead) return { ok: false, reason: 'dead' }
|
|
70
|
+
if (state.activity !== null && action !== 'pet') return { ok: false, reason: 'away' }
|
|
71
|
+
|
|
72
|
+
const wait = actionCooldownSeconds(state, action, nowMs)
|
|
73
|
+
if (wait > 0) return { ok: false, reason: 'cooldown', wait }
|
|
74
|
+
|
|
75
|
+
// Feeding, washing and playing each spend something off their own shelf —
|
|
76
|
+
// QQ Pet keeps food, sundries and medicine in separate inventory categories
|
|
77
|
+
// for exactly this reason. Petting is affection, and costs nothing.
|
|
78
|
+
const shelf = CARE_KIND[action]
|
|
79
|
+
let item = null
|
|
80
|
+
if (shelf !== undefined) {
|
|
81
|
+
item = resolveCareItem(state, shelf, itemKey)
|
|
82
|
+
if (item === null) return { ok: false, reason: 'no-item', kind: shelf }
|
|
83
|
+
if (item.default !== true) {
|
|
84
|
+
const left = (state.inventory?.[item.key] ?? 0) - 1
|
|
85
|
+
state.inventory = { ...(state.inventory ?? {}) }
|
|
86
|
+
if (left > 0) state.inventory[item.key] = left
|
|
87
|
+
else delete state.inventory[item.key]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.cooldowns = { ...(state.cooldowns ?? {}), [action]: nowMs }
|
|
92
|
+
if (action === 'feed') { state.stats.feeds += 1; state.lastFedAt = nowMs }
|
|
93
|
+
else if (action === 'bathe') state.stats.baths += 1
|
|
94
|
+
else if (action === 'play') state.stats.plays += 1
|
|
95
|
+
else if (action === 'pet') state.stats.pets += 1
|
|
96
|
+
|
|
97
|
+
const satietyBefore = state.satiety
|
|
98
|
+
applyEffects(state, careEffects(item, spec), nowMs)
|
|
99
|
+
remember(state, item === null ? spec.verb : `${item.emoji} ${spec.label}用了「${item.label}」`, nowMs)
|
|
100
|
+
if (action === 'feed') rollForOverfeeding(state, satietyBefore, nowMs)
|
|
101
|
+
// Feeding a pig that was already stuffed gets a different complaint.
|
|
102
|
+
say(state, action === 'feed' && satietyBefore >= ILLNESS_ONSET.overfullAt ? 'overfull' : CARE_SCENE[action], nowMs)
|
|
103
|
+
noteToday(state, action)
|
|
104
|
+
return { ok: true, item: item === null ? null : item.key, spent: item !== null && item.default !== true }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The item an action will spend: the caller's pick when it is actually in the
|
|
109
|
+
* bag, otherwise the cheapest thing the pig owns. The free default toy makes
|
|
110
|
+
* `play` always available.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveCareItem(state, kind, wanted) {
|
|
113
|
+
const usable = careItems(kind).filter(
|
|
114
|
+
item => item.default === true || (state.inventory?.[item.key] ?? 0) > 0,
|
|
115
|
+
)
|
|
116
|
+
if (usable.length === 0) return null
|
|
117
|
+
if (wanted === undefined || wanted === null || wanted === '') return usable[0]
|
|
118
|
+
return usable.find(item => item.key === wanted) ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Blend an item's own effects with the action's baseline. The item decides how
|
|
123
|
+
* far its own bar moves; the action keeps whatever the item does not mention.
|
|
124
|
+
*/
|
|
125
|
+
export function careEffects(item, spec) {
|
|
126
|
+
if (item === null) return spec
|
|
127
|
+
return {
|
|
128
|
+
weightG: item.satiety !== undefined && spec.key === 'feed' ? spec.weightG : 0,
|
|
129
|
+
satiety: item.satiety ?? spec.satiety,
|
|
130
|
+
happiness: item.happiness ?? spec.happiness,
|
|
131
|
+
cleanliness: item.cleanliness ?? spec.cleanliness,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What each care action could be done with right now, and how many are left. */
|
|
136
|
+
export function careOptions(state, action) {
|
|
137
|
+
const kind = CARE_KIND[action]
|
|
138
|
+
if (kind === undefined) return []
|
|
139
|
+
return careItems(kind)
|
|
140
|
+
.filter(item => item.default === true || (state.inventory?.[item.key] ?? 0) > 0)
|
|
141
|
+
.map(item => ({
|
|
142
|
+
key: item.key,
|
|
143
|
+
label: item.label,
|
|
144
|
+
emoji: item.emoji,
|
|
145
|
+
price: item.price,
|
|
146
|
+
default: item.default === true,
|
|
147
|
+
count: item.default === true ? null : (state.inventory?.[item.key] ?? 0),
|
|
148
|
+
satiety: item.satiety ?? 0,
|
|
149
|
+
happiness: item.happiness ?? 0,
|
|
150
|
+
cleanliness: item.cleanliness ?? 0,
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Activities: work · study · trip
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export const canFeed = (state, nowMs) => actionReady(state, 'feed', nowMs)
|
|
159
|
+
|
|
160
|
+
export const feedCooldownSeconds = (state, nowMs) => actionCooldownSeconds(state, 'feed', nowMs)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 时间、年龄、等级派生。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/clock
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DAYS_PER_MONTH, DAY_STARTS_AT_HOUR, GRAVE, GROWTH_PER_HOUR, LEVEL_TITLES, LIFE_STAGES, MAX_LEVEL, SOUL_AFTER_DAYS, xpForLevel } from '../data.js'
|
|
10
|
+
import { DAY_MS } from './constants.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The pig's age in days.
|
|
14
|
+
*
|
|
15
|
+
* This is accumulated *pig time*, not wall clock: `decay()` adds elapsed real
|
|
16
|
+
* time times the time scale. Storing it this way means changing the scale only
|
|
17
|
+
* affects the future — the pig does not suddenly jump from a piglet to elderly
|
|
18
|
+
* because you raised the multiplier.
|
|
19
|
+
*/
|
|
20
|
+
export function ageDays(state, nowMs) {
|
|
21
|
+
if (state === null) return 0
|
|
22
|
+
if (typeof state.ageMs === 'number' && Number.isFinite(state.ageMs)) {
|
|
23
|
+
return Math.max(0, state.ageMs / DAY_MS)
|
|
24
|
+
}
|
|
25
|
+
// Saves from before pig time existed only have a birthday.
|
|
26
|
+
if (typeof state.bornAt !== 'number') return 0
|
|
27
|
+
return Math.max(0, (nowMs - state.bornAt) / DAY_MS)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The pig's age in whole months, for display. */
|
|
31
|
+
export const ageMonths = (state, nowMs) => ageDays(state, nowMs) / DAYS_PER_MONTH
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which stage the pig is at right now: a box, a pig of some size, or a grave.
|
|
35
|
+
* The body follows the level, not the calendar (B2).
|
|
36
|
+
*/
|
|
37
|
+
export function lifeStageFor(state, nowMs) {
|
|
38
|
+
if (state === null) return LIFE_STAGES[0]
|
|
39
|
+
if (state.dead === true) return GRAVE
|
|
40
|
+
if (state.hatched !== true) return LIFE_STAGES[0]
|
|
41
|
+
const level = levelFor(state.xp)
|
|
42
|
+
let stage = LIFE_STAGES[1]
|
|
43
|
+
for (const candidate of LIFE_STAGES) {
|
|
44
|
+
if (candidate.box === true) continue
|
|
45
|
+
if (level >= (candidate.fromLevel ?? 0)) stage = candidate
|
|
46
|
+
}
|
|
47
|
+
return stage
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The next rung, or null once the pig is fully grown. */
|
|
51
|
+
export function nextLifeStage(state, nowMs) {
|
|
52
|
+
if (state === null || state.dead === true || state.hatched !== true) return null
|
|
53
|
+
const level = levelFor(state.xp)
|
|
54
|
+
return LIFE_STAGES.find(stage => stage.box !== true && (stage.fromLevel ?? 0) > level) ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Roughly how many days until the next rung, at full care and the base rate.
|
|
59
|
+
* An estimate for the panel, not a promise: care and activities move it.
|
|
60
|
+
*/
|
|
61
|
+
export function daysToNextStage(state, nowMs) {
|
|
62
|
+
const next = nextLifeStage(state, nowMs)
|
|
63
|
+
if (next === null) return null
|
|
64
|
+
const remaining = Math.max(0, xpForLevel(next.fromLevel ?? 0) - (Number.isFinite(state.xp) ? state.xp : 0))
|
|
65
|
+
return remaining / GROWTH_PER_HOUR / 24
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The day an instant belongs to, as 'YYYY-MM-DD' in local time. A day starts
|
|
70
|
+
* at DAY_STARTS_AT_HOUR (06:00), so staying up past midnight still counts as
|
|
71
|
+
* the evening before. Daily caps, sign-in and the diary all key off this.
|
|
72
|
+
* @param {number} nowMs
|
|
73
|
+
*/
|
|
74
|
+
export function dayKeyFor(nowMs) {
|
|
75
|
+
const shifted = new Date(nowMs - DAY_STARTS_AT_HOUR * 3_600_000)
|
|
76
|
+
const month = String(shifted.getMonth() + 1).padStart(2, '0')
|
|
77
|
+
const day = String(shifted.getDate()).padStart(2, '0')
|
|
78
|
+
return `${shifted.getFullYear()}-${month}-${day}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Level
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** The pig's level for a growth total, 1..MAX_LEVEL. */
|
|
86
|
+
export function levelFor(xp) {
|
|
87
|
+
const value = Number.isFinite(xp) ? Math.max(0, xp) : 0
|
|
88
|
+
let level = 1
|
|
89
|
+
while (level < MAX_LEVEL && value >= xpForLevel(level + 1)) level += 1
|
|
90
|
+
return level
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The title earned at this level. */
|
|
94
|
+
export function levelTitle(level) {
|
|
95
|
+
let found = LEVEL_TITLES[0]
|
|
96
|
+
for (const entry of LEVEL_TITLES) if (level >= entry.level) found = entry
|
|
97
|
+
return found
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How far into the current level, 0-100, plus the numbers behind it. */
|
|
101
|
+
export function levelProgress(xp) {
|
|
102
|
+
const value = Number.isFinite(xp) ? Math.max(0, xp) : 0
|
|
103
|
+
const level = levelFor(value)
|
|
104
|
+
const floor = xpForLevel(level)
|
|
105
|
+
const maxed = level >= MAX_LEVEL
|
|
106
|
+
const ceiling = maxed ? floor : xpForLevel(level + 1)
|
|
107
|
+
const span = Math.max(1, ceiling - floor)
|
|
108
|
+
return {
|
|
109
|
+
level,
|
|
110
|
+
xp: value,
|
|
111
|
+
floor,
|
|
112
|
+
ceiling,
|
|
113
|
+
maxed,
|
|
114
|
+
toNext: maxed ? 0 : Math.max(0, ceiling - value),
|
|
115
|
+
percent: maxed ? 100 : Math.max(0, Math.min(100, Math.round(((value - floor) / span) * 100))),
|
|
116
|
+
title: levelTitle(level),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Has the grave been left alone long enough for the soul to settle on it? */
|
|
121
|
+
export const hasSoul = (state, nowMs) =>
|
|
122
|
+
state !== null && state.dead === true && (nowMs - (state.diedAt ?? nowMs)) / DAY_MS >= SOUL_AFTER_DAYS
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 领域常量与动作表。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数领域逻辑:时间由 nowMs 传入,不读写文件、不碰 DOM(见 docs/CONVENTIONS.md)。
|
|
6
|
+
* @module dsh-piggy/core/constants
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Bumped when the saved shape changes in a way migrate() must handle. */
|
|
10
|
+
export const STATE_VERSION = 12
|
|
11
|
+
|
|
12
|
+
export const BIRTH_WEIGHT_G = 1200
|
|
13
|
+
|
|
14
|
+
export const HATCH_WEIGHT_G = 160
|
|
15
|
+
|
|
16
|
+
export const MEMORY_LIMIT = 8
|
|
17
|
+
|
|
18
|
+
export const PENDING_LIMIT = 6
|
|
19
|
+
|
|
20
|
+
/** Growth ladder, ascending by xp. */
|
|
21
|
+
/**
|
|
22
|
+
* Growth ladder, ascending by xp.
|
|
23
|
+
*
|
|
24
|
+
* Tuned so that ordinary passive work alone takes roughly: 7 minutes to 小猪崽,
|
|
25
|
+
* 40 minutes to 圆滚猪, an afternoon to 大猪猪, a few days to 猪皇, a week or so
|
|
26
|
+
* to 猪王, and the best part of a month of real use to 野猪王. Sending the pig
|
|
27
|
+
* out to work or study is worth several hours of watching you type, so playing
|
|
28
|
+
* the game is the fast lane.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Passive diet — what the pig gets for watching you actually work. Growth from
|
|
33
|
+
* real work is separate and capped per day (data/growth.js DSH_GROWTH).
|
|
34
|
+
*
|
|
35
|
+
* These numbers are deliberately small. A single tool call used to be worth 3,
|
|
36
|
+
* and a heavy agent session fires hundreds of them an hour: one afternoon of
|
|
37
|
+
* ordinary use was 2232 XP, 94% of the whole pig, and it hit 「猪皇」 without the
|
|
38
|
+
* player ever sending it to work or school. Passive work is now a trickle; the
|
|
39
|
+
* activities are where the growth is.
|
|
40
|
+
*/
|
|
41
|
+
export const DIET = Object.freeze({
|
|
42
|
+
message: { satiety: 1, happiness: 1, weightG: 6 },
|
|
43
|
+
turn: { satiety: 2, happiness: 1, weightG: 14 },
|
|
44
|
+
tool: { satiety: 2, happiness: 0, weightG: 9 },
|
|
45
|
+
toolError: { satiety: 0, happiness: 1, weightG: 2 },
|
|
46
|
+
agentError: { satiety: 0, happiness: 0, weightG: 2 },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const ACTIONS = Object.freeze({
|
|
50
|
+
feed: {
|
|
51
|
+
key: 'feed', label: '喂食', emoji: '🍎', verb: '吃了一口 🍎',
|
|
52
|
+
// No blanket cleanliness hit: eating an apple does not make you dirty. Only
|
|
53
|
+
// the foods that are actually messy declare a penalty of their own.
|
|
54
|
+
// No cooldown (owner, 2026-10-01): every bite already costs a food item,
|
|
55
|
+
// and feeding a full pig risks a stomach ache (B3) — that is the brake.
|
|
56
|
+
cooldownMs: 0, satiety: 22, happiness: 6, cleanliness: 0, weightG: 90,
|
|
57
|
+
},
|
|
58
|
+
bathe: {
|
|
59
|
+
key: 'bathe', label: '洗澡', emoji: '🛁', verb: '洗了个澡 🛁',
|
|
60
|
+
cooldownMs: 90_000, satiety: -2, happiness: 8, cleanliness: 50, weightG: 0,
|
|
61
|
+
},
|
|
62
|
+
play: {
|
|
63
|
+
key: 'play', label: '玩耍', emoji: '🎾', verb: '玩了一会儿 🎾',
|
|
64
|
+
cooldownMs: 45_000, satiety: -5, happiness: 16, cleanliness: -4, weightG: 4,
|
|
65
|
+
},
|
|
66
|
+
pet: {
|
|
67
|
+
key: 'pet', label: '摸摸', emoji: '❤️', verb: '被摸了摸头 ❤️',
|
|
68
|
+
cooldownMs: 0, satiety: 0, happiness: 10, cleanliness: 0, weightG: 0,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const ACTION_ORDER = Object.freeze(['feed', 'bathe', 'play', 'pet'])
|
|
73
|
+
|
|
74
|
+
// Tuned against the activity lengths, not against minutes. A four-hour shift
|
|
75
|
+
// now costs roughly one meal and one bath, which is recoverable; the old rates
|
|
76
|
+
// were written when a shift was ten minutes and emptied every bar twice over.
|
|
77
|
+
export const SATIETY_DECAY_PER_MIN = 0.08
|
|
78
|
+
|
|
79
|
+
export const HAPPINESS_DECAY_PER_MIN = 0.06
|
|
80
|
+
|
|
81
|
+
export const CLEANLINESS_DECAY_PER_MIN = 0.07
|
|
82
|
+
|
|
83
|
+
export const AWAY_DECAY_MULTIPLIER = 1.4
|
|
84
|
+
|
|
85
|
+
/** No single trip may push a bar below this — see the drain() comment in decay. */
|
|
86
|
+
export const AWAY_FLOOR = 15
|
|
87
|
+
|
|
88
|
+
export const DAY_MS = 86_400_000
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The longest single step decay() takes. Long absences are walked in steps of
|
|
92
|
+
* this size so thresholds are crossed when they really were; five minutes keeps
|
|
93
|
+
* a month offline under ten thousand cheap steps.
|
|
94
|
+
*/
|
|
95
|
+
export const SETTLE_STEP_MS = 5 * 60_000
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Health at or below which the pig is too weak to leave the house. The scale is
|
|
99
|
+
* 5 = full, and the four illness stages set it to 4/3/2/1, so 1 is the last
|
|
100
|
+
* stage before it dies.
|
|
101
|
+
*/
|
|
102
|
+
export const TOO_WEAK_HEALTH = 1
|
|
103
|
+
|
|
104
|
+
export const AWAY_MOODS = Object.freeze({
|
|
105
|
+
work: { key: 'working', emoji: '💼', label: '在打工' },
|
|
106
|
+
study: { key: 'studying', emoji: '📚', label: '在上课' },
|
|
107
|
+
trip: { key: 'traveling', emoji: '🧳', label: '在旅行' },
|
|
108
|
+
interest: { key: 'studying', emoji: '💻', label: '在兴趣课' },
|
|
109
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 日常:签到 12 天、在线礼包、宠物日记。
|
|
4
|
+
*
|
|
5
|
+
* 纯领域逻辑:时间由 nowMs 传入,随机走 `core/random.js`,不读文件、不碰 DOM。
|
|
6
|
+
* 一天的边界是早上 06:00 —— `dayKeyFor()` 就在 clock.js,这里只是转出去,
|
|
7
|
+
* 免得「一天从几点算起」有两份实现(见 docs/CONVENTIONS.md)。
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-piggy/core/daily
|
|
10
|
+
*/
|
|
11
|
+
import { GIFT_TABLE, ONLINE_GIFT, SIGN_IN_CYCLE, SIGN_IN_REWARDS, SHOP, itemByKey } from '../data.js'
|
|
12
|
+
import { dayKeyFor } from './clock.js'
|
|
13
|
+
import { rollerFor } from './random.js'
|
|
14
|
+
import { announce } from './effects.js'
|
|
15
|
+
import { say } from './lines.js'
|
|
16
|
+
|
|
17
|
+
export { dayKeyFor }
|
|
18
|
+
|
|
19
|
+
/** 一份全新的日常状态:今天没签、没在线、没礼包。 */
|
|
20
|
+
export function emptyDaily() {
|
|
21
|
+
return {
|
|
22
|
+
signIn: { lastDay: null, index: 0, total: 0 },
|
|
23
|
+
online: { day: null, onlineMs: 0, given: 0, unclaimed: 0 },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 补默认值。新字段不加 upgrades 级、不动 STATE_VERSION(那归 Claude):
|
|
29
|
+
* 缺什么补什么,坏值当没有(参考 core/lines.js 的 ensureDialogue)。
|
|
30
|
+
* @param {object} state
|
|
31
|
+
*/
|
|
32
|
+
export function ensureDaily(state) {
|
|
33
|
+
const raw = state.daily
|
|
34
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
35
|
+
state.daily = emptyDaily()
|
|
36
|
+
return state.daily
|
|
37
|
+
}
|
|
38
|
+
const signIn = raw.signIn !== null && typeof raw.signIn === 'object' && !Array.isArray(raw.signIn) ? raw.signIn : {}
|
|
39
|
+
const online = raw.online !== null && typeof raw.online === 'object' && !Array.isArray(raw.online) ? raw.online : {}
|
|
40
|
+
const index = Number.isInteger(signIn.index) && signIn.index >= 0 ? signIn.index % SIGN_IN_CYCLE : 0
|
|
41
|
+
state.daily = {
|
|
42
|
+
signIn: {
|
|
43
|
+
lastDay: typeof signIn.lastDay === 'string' && signIn.lastDay !== '' ? signIn.lastDay : null,
|
|
44
|
+
index,
|
|
45
|
+
total: Number.isInteger(signIn.total) && signIn.total >= 0 ? signIn.total : 0,
|
|
46
|
+
},
|
|
47
|
+
online: {
|
|
48
|
+
day: typeof online.day === 'string' && online.day !== '' ? online.day : null,
|
|
49
|
+
onlineMs: Number.isFinite(online.onlineMs) && online.onlineMs > 0 ? online.onlineMs : 0,
|
|
50
|
+
given: Number.isInteger(online.given) && online.given >= 0 ? online.given : 0,
|
|
51
|
+
unclaimed: Number.isInteger(online.unclaimed) && online.unclaimed >= 0 ? online.unclaimed : 0,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return state.daily
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把一份奖励记到账上。
|
|
59
|
+
* @param {object} state
|
|
60
|
+
* @param {{coins: number, items: ReadonlyArray<{key: string, count: number}>}} reward
|
|
61
|
+
* @returns {string} 领到了什么(给公告用)
|
|
62
|
+
*/
|
|
63
|
+
export function grantReward(state, reward) {
|
|
64
|
+
const parts = []
|
|
65
|
+
if (reward.coins > 0) {
|
|
66
|
+
state.coins += reward.coins
|
|
67
|
+
parts.push(`🪙 ${reward.coins}`)
|
|
68
|
+
}
|
|
69
|
+
for (const entry of reward.items) {
|
|
70
|
+
state.inventory = { ...(state.inventory ?? {}) }
|
|
71
|
+
state.inventory[entry.key] = (state.inventory[entry.key] ?? 0) + entry.count
|
|
72
|
+
const item = itemByKey(entry.key)
|
|
73
|
+
// A key the shelf does not know yet (B3's medicine) still lands in the bag.
|
|
74
|
+
parts.push(`${item === null ? '🎁' : item.emoji} ${item === null ? entry.key : item.label} ×${entry.count}`)
|
|
75
|
+
}
|
|
76
|
+
return parts.join(' + ')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 今天这一签领了没。 */
|
|
80
|
+
export function canSignIn(state, nowMs) {
|
|
81
|
+
return ensureDaily(state).signIn.lastDay !== dayKeyFor(nowMs)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 签到:发当天的礼包、推进一轮,写回今天是哪一天。
|
|
86
|
+
*
|
|
87
|
+
* 断签不清零(下次接着领下一天),12 天领完从头来;死了也能签 —— 墓碑也攒还魂丹。
|
|
88
|
+
* @returns {{ ok: boolean, reason?: string, day?: number, reward?: string }}
|
|
89
|
+
*/
|
|
90
|
+
export function signIn(state, nowMs) {
|
|
91
|
+
const daily = ensureDaily(state)
|
|
92
|
+
const today = dayKeyFor(nowMs)
|
|
93
|
+
if (daily.signIn.lastDay === today) return { ok: false, reason: 'signed' }
|
|
94
|
+
const index = daily.signIn.index % SIGN_IN_CYCLE
|
|
95
|
+
const text = grantReward(state, SIGN_IN_REWARDS[index])
|
|
96
|
+
daily.signIn.lastDay = today
|
|
97
|
+
daily.signIn.index = (index + 1) % SIGN_IN_CYCLE
|
|
98
|
+
daily.signIn.total += 1
|
|
99
|
+
announce(state, 'gift', `签到第 ${index + 1} 天:${text}`, nowMs)
|
|
100
|
+
say(state, 'signIn', nowMs)
|
|
101
|
+
return { ok: true, day: index + 1, reward: text }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 记一段在线时长。
|
|
106
|
+
*
|
|
107
|
+
* 纯函数:`lastPollMs` 是上一次轮询的时刻,由宿主(store)传进来。
|
|
108
|
+
* **只算真实毫秒**,跟调试页的时间倍率无关;两次轮询间隔超过 30 秒就当作
|
|
109
|
+
* 「人不在」,中间那段不算。跨天(06:00)时清零当日进度,但**没领的礼包留着**。
|
|
110
|
+
*/
|
|
111
|
+
export function recordOnline(state, lastPollMs, nowMs) {
|
|
112
|
+
const daily = ensureDaily(state)
|
|
113
|
+
const today = dayKeyFor(nowMs)
|
|
114
|
+
if (daily.online.day !== today) {
|
|
115
|
+
daily.online.day = today
|
|
116
|
+
daily.online.onlineMs = 0
|
|
117
|
+
daily.online.given = 0
|
|
118
|
+
// unclaimed 不清:攒着的礼包换天还在,用户回来能一起领。
|
|
119
|
+
}
|
|
120
|
+
const gap = nowMs - lastPollMs
|
|
121
|
+
if (lastPollMs > 0 && gap > 0 && gap <= ONLINE_GIFT.pollGapMaxMs) daily.online.onlineMs += gap
|
|
122
|
+
// 每满一小时一个。到了当天的上限(或攒满 3 个)就只消耗时长,不再给。
|
|
123
|
+
while (daily.online.onlineMs >= ONLINE_GIFT.perGiftMs) {
|
|
124
|
+
daily.online.onlineMs -= ONLINE_GIFT.perGiftMs
|
|
125
|
+
if (daily.online.given < ONLINE_GIFT.perDay && daily.online.unclaimed < ONLINE_GIFT.unclaimedMax) {
|
|
126
|
+
daily.online.given += 1
|
|
127
|
+
daily.online.unclaimed += 1
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return daily.online
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 抽一个礼包内容(纯函数,随机来自 state.seed)。
|
|
135
|
+
* @returns {{coins: number, items: ReadonlyArray<{key: string, count: number}>}}
|
|
136
|
+
*/
|
|
137
|
+
export function pickGift(state, next) {
|
|
138
|
+
return resolveGiftBucket(GIFT_TABLE[giftBucketIndex(next())], next)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Which row of `GIFT_TABLE` a `[0, 1)` roll lands on(抽出来是为了能测分布)。
|
|
143
|
+
* @param {number} roll
|
|
144
|
+
* @returns {number} GIFT_TABLE 的下标
|
|
145
|
+
*/
|
|
146
|
+
export function giftBucketIndex(roll) {
|
|
147
|
+
let accumulated = 0
|
|
148
|
+
for (let index = 0; index < GIFT_TABLE.length; index += 1) {
|
|
149
|
+
accumulated += GIFT_TABLE[index].chance
|
|
150
|
+
if (roll < accumulated) return index
|
|
151
|
+
}
|
|
152
|
+
// Floating point can leave the sum a hair under 1: fall back to the common case.
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {object} bucket @param {() => number} next */
|
|
157
|
+
function resolveGiftBucket(bucket, next) {
|
|
158
|
+
if (bucket.coins !== undefined) {
|
|
159
|
+
const [low, high] = bucket.coins
|
|
160
|
+
return { coins: low + Math.floor(next() * (high - low + 1)), items: [] }
|
|
161
|
+
}
|
|
162
|
+
if (bucket.keys !== undefined && bucket.keys.length > 0) {
|
|
163
|
+
const key = bucket.keys[Math.min(bucket.keys.length - 1, Math.floor(next() * bucket.keys.length))]
|
|
164
|
+
return { coins: 0, items: [{ key, count: 1 }] }
|
|
165
|
+
}
|
|
166
|
+
const kinds = bucket.kinds ?? (bucket.kind === undefined ? [] : [bucket.kind])
|
|
167
|
+
const pool = SHOP.filter(item => kinds.includes(item.kind)
|
|
168
|
+
&& (bucket.maxPrice === undefined || item.price <= bucket.maxPrice))
|
|
169
|
+
if (pool.length === 0) return { coins: 30, items: [] }
|
|
170
|
+
const item = pool[Math.min(pool.length - 1, Math.floor(next() * pool.length))]
|
|
171
|
+
return { coins: 0, items: [{ key: item.key, count: 1 }] }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 现在有没有没领的礼包。 */
|
|
175
|
+
export function giftsWaiting(state) {
|
|
176
|
+
return ensureDaily(state).online.unclaimed
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 开一个在线礼包:抽一项、发到账上、公告 + 说一句。
|
|
181
|
+
* @returns {{ ok: boolean, reason?: string, reward?: string }}
|
|
182
|
+
*/
|
|
183
|
+
export function openGift(state, nowMs) {
|
|
184
|
+
const daily = ensureDaily(state)
|
|
185
|
+
if (daily.online.unclaimed <= 0) return { ok: false, reason: 'empty' }
|
|
186
|
+
daily.online.unclaimed -= 1
|
|
187
|
+
const text = grantReward(state, pickGift(state, rollerFor(state)))
|
|
188
|
+
announce(state, 'gift', `在线礼包:${text}`, nowMs)
|
|
189
|
+
say(state, 'gift', nowMs)
|
|
190
|
+
return { ok: true, reward: text }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 面板需要的那几个数。 */
|
|
194
|
+
export function dailyView(state, nowMs) {
|
|
195
|
+
const daily = ensureDaily(state)
|
|
196
|
+
return {
|
|
197
|
+
canSignIn: daily.signIn.lastDay !== dayKeyFor(nowMs),
|
|
198
|
+
signInDay: (daily.signIn.index % SIGN_IN_CYCLE) + 1,
|
|
199
|
+
signInTotal: daily.signIn.total,
|
|
200
|
+
cycle: SIGN_IN_CYCLE,
|
|
201
|
+
unclaimed: daily.online.unclaimed,
|
|
202
|
+
onlineMinutes: Math.floor(daily.online.onlineMs / 60000),
|
|
203
|
+
}
|
|
204
|
+
}
|