dsh-piggy 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +1348 -0
  2. package/LICENSE +32 -0
  3. package/README.md +322 -0
  4. package/THIRD-PARTY.md +31 -0
  5. package/assets/elder.svg +35 -0
  6. package/assets/pig-king-bathe.svg +38 -0
  7. package/assets/pig-king-eat.svg +45 -0
  8. package/assets/pig-king-pet.svg +45 -0
  9. package/assets/pig-king-play.svg +45 -0
  10. package/assets/pig-king-relaxed.svg +56 -0
  11. package/assets/pig-king-study.svg +45 -0
  12. package/assets/pig-king-trip.svg +45 -0
  13. package/assets/pig-king-work.svg +45 -0
  14. package/assets/pig-king.svg +56 -0
  15. package/assets/piglet.svg +30 -0
  16. package/client.js +3438 -0
  17. package/commands.js +252 -0
  18. package/cordis.patch.yml +15 -0
  19. package/core.js +11 -0
  20. package/data.js +10 -0
  21. package/index.js +68 -0
  22. package/package.json +72 -0
  23. package/packages/pet-core/package.json +21 -0
  24. package/packages/pet-core/src/core/activity.js +80 -0
  25. package/packages/pet-core/src/core/care.js +160 -0
  26. package/packages/pet-core/src/core/clock.js +122 -0
  27. package/packages/pet-core/src/core/constants.js +109 -0
  28. package/packages/pet-core/src/core/daily.js +204 -0
  29. package/packages/pet-core/src/core/diary.js +108 -0
  30. package/packages/pet-core/src/core/drops.js +82 -0
  31. package/packages/pet-core/src/core/effects.js +84 -0
  32. package/packages/pet-core/src/core/egg.js +120 -0
  33. package/packages/pet-core/src/core/evolution.js +90 -0
  34. package/packages/pet-core/src/core/growth.js +110 -0
  35. package/packages/pet-core/src/core/illness.js +235 -0
  36. package/packages/pet-core/src/core/interests.js +55 -0
  37. package/packages/pet-core/src/core/inventory.js +177 -0
  38. package/packages/pet-core/src/core/lines.js +157 -0
  39. package/packages/pet-core/src/core/migrate.js +226 -0
  40. package/packages/pet-core/src/core/profile.js +102 -0
  41. package/packages/pet-core/src/core/random.js +79 -0
  42. package/packages/pet-core/src/core/school.js +81 -0
  43. package/packages/pet-core/src/core/settlement.js +308 -0
  44. package/packages/pet-core/src/core/state.js +210 -0
  45. package/packages/pet-core/src/core/travel.js +58 -0
  46. package/packages/pet-core/src/core/upgrades.js +193 -0
  47. package/packages/pet-core/src/core/views.js +55 -0
  48. package/packages/pet-core/src/core/work.js +50 -0
  49. package/packages/pet-core/src/core.js +95 -0
  50. package/packages/pet-core/src/data/daily.js +121 -0
  51. package/packages/pet-core/src/data/drops.js +34 -0
  52. package/packages/pet-core/src/data/evolution.js +41 -0
  53. package/packages/pet-core/src/data/growth.js +72 -0
  54. package/packages/pet-core/src/data/illness.js +188 -0
  55. package/packages/pet-core/src/data/interests.js +39 -0
  56. package/packages/pet-core/src/data/jobs.js +150 -0
  57. package/packages/pet-core/src/data/life.js +88 -0
  58. package/packages/pet-core/src/data/lines.js +198 -0
  59. package/packages/pet-core/src/data/minutes.js +23 -0
  60. package/packages/pet-core/src/data/profile.js +60 -0
  61. package/packages/pet-core/src/data/school.js +99 -0
  62. package/packages/pet-core/src/data/shop.js +142 -0
  63. package/packages/pet-core/src/data/traits.js +42 -0
  64. package/packages/pet-core/src/data/travel.js +87 -0
  65. package/packages/pet-core/src/data.js +52 -0
  66. package/packages/pet-core/src/index.js +12 -0
  67. package/render.js +357 -0
  68. package/routes.js +201 -0
  69. package/scripts/build-client.mjs +100 -0
  70. package/scripts/typecheck.mjs +122 -0
  71. package/snapshot.js +368 -0
  72. package/src/client/art.js +29 -0
  73. package/src/client/constants.js +88 -0
  74. package/src/client/css-base.js +225 -0
  75. package/src/client/css-card.js +63 -0
  76. package/src/client/css-tabs.js +250 -0
  77. package/src/client/css-tiles.js +89 -0
  78. package/src/client/dom.js +33 -0
  79. package/src/client/effects.js +159 -0
  80. package/src/client/format.js +27 -0
  81. package/src/client/index.js +393 -0
  82. package/src/client/io.js +100 -0
  83. package/src/client/layout.js +103 -0
  84. package/src/client/normalize.js +352 -0
  85. package/src/client/panel.js +319 -0
  86. package/src/client/scene.js +113 -0
  87. package/src/client/storage.js +19 -0
  88. package/src/client/styles.js +13 -0
  89. package/src/client/tabs/bag.js +203 -0
  90. package/src/client/tabs/card.js +110 -0
  91. package/src/client/tabs/crown.js +59 -0
  92. package/src/client/tabs/dev.js +101 -0
  93. package/src/client/tabs/home.js +72 -0
  94. package/src/client/tabs/shop.js +78 -0
  95. package/src/client/tabs/status.js +223 -0
  96. package/src/client/tabs/study.js +128 -0
  97. package/src/client/tabs/travel.js +89 -0
  98. package/src/client/tabs/update.js +164 -0
  99. package/src/client/tabs/work.js +106 -0
  100. package/src/client/values.js +11 -0
  101. package/src/client/widgets.js +126 -0
  102. package/store/api.js +260 -0
  103. package/store/state-file.js +129 -0
  104. package/store.js +96 -0
@@ -0,0 +1,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
+ }