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,100 @@
1
+ // @ts-check
2
+ /**
3
+ * 与宿主对话:拉快照、发动作。
4
+ *
5
+ * 只负责 HTTP 与错误话术,画界面交给 panel.js(见 docs/CONVENTIONS.md)。
6
+ * @module dsh-piggy/client/io
7
+ */
8
+ import { ACT_URL, NO_ITEM_LINE, STATE_URL } from './constants.js'
9
+ import { el } from './dom.js'
10
+ import { num, str } from './values.js'
11
+
12
+ /**
13
+ * @param {object} ctx - the shell context
14
+ */
15
+ export function createIo(ctx) {
16
+ /** Bumped by every action, so a stale poll can tell it has been overtaken. */
17
+ var actionSeq = 0
18
+
19
+ async function send(action, extra) {
20
+ if (ctx.busy || ctx.stopped) return
21
+ if (ctx.view.pig === null && action !== 'hatch') return
22
+ // Every action bumps the sequence: a poll that started before this
23
+ // action is stale by the time it lands and must be dropped.
24
+ actionSeq += 1
25
+ ctx.busy = true
26
+ ctx.flash(action)
27
+ try {
28
+ var body = { action: action }
29
+ if (extra) for (var k in extra) body[k] = extra[k]
30
+ var res = await fetch(ACT_URL, {
31
+ method: 'POST',
32
+ headers: { 'content-type': 'application/json' },
33
+ body: JSON.stringify(body),
34
+ })
35
+ var next = await res.json()
36
+ ctx.render(next)
37
+ if (next && next.ok === false) {
38
+ // Answering a line that has already moved on is normal (a second
39
+ // window, a slow poll): say nothing rather than scold the user.
40
+ if (next.reason === 'stale-line') return
41
+ // The pig chose not to speak up (away, ill, 免打扰): nothing to say.
42
+ if (next.reason === 'silent') return
43
+ ctx.react('refuse', 520)
44
+ if (next.reason === 'no-item') {
45
+ var emptyKind = str(next.kind, '')
46
+ ctx.showBubble(NO_ITEM_LINE[emptyKind] ?? '背包里没有能用的东西', 3200)
47
+ return
48
+ }
49
+ var reasons = {
50
+ box: '先把纸盒拆开',
51
+ 'coronation-ineligible': '加冕条件还没齐',
52
+ cooldown: '还要等 ' + num(next.wait, 0) + ' 秒',
53
+ poor: '钱不够',
54
+ away: '它在外面',
55
+ weak: '太虚弱了,先养好再出门',
56
+ hungry: '太饿了',
57
+ 'wrong-medicine': '药不对症,病情加重了…',
58
+ empty: '背包里没有',
59
+ 'not-sick': '它没生病',
60
+ dead: '它已经走了…',
61
+ idle: '它没在外面',
62
+ owned: '这件已经有了',
63
+ 'low-level': '等级不够(要 Lv.' + num(next.need, 0) + ',现在 Lv.' + num(next.have, 0) + ')',
64
+ 'not-owned': '还没有这件东西',
65
+ 'not-consumable': '这个是穿的,不是用的',
66
+ 'wrong-stage': '这个学段没有这门课',
67
+ underqualified: '它还没这个本事,先去上课',
68
+ }
69
+ ctx.showBubble(reasons[next.reason] ?? '这个操作没成', 2400)
70
+ }
71
+ } catch (error) {
72
+ ctx.showBubble('操作没送到宿主', 2600)
73
+ ctx.react('refuse', 520)
74
+ } finally {
75
+ ctx.busy = false
76
+ }
77
+ }
78
+
79
+ async function refresh() {
80
+ if (ctx.stopped) return
81
+ // A poll can change the live content (a job finishing, an illness
82
+ // starting), so re-check the panel still fits.
83
+ ctx.fitPanel()
84
+ var startedAt = actionSeq
85
+ try {
86
+ var res = await fetch(STATE_URL, { cache: 'no-store' })
87
+ if (!res.ok) throw new Error('HTTP ' + res.status)
88
+ var next = await res.json()
89
+ // An action landed while this poll was in flight: its result is newer
90
+ // than ours, so painting ours would undo what the user just did.
91
+ if (startedAt !== actionSeq) return
92
+ ctx.render(next)
93
+ } catch (error) {
94
+ if (ctx.stopped) return
95
+ ctx.showBubble('连接不上宿主', 4000)
96
+ }
97
+ }
98
+
99
+ return { send: send, refresh: refresh }
100
+ }
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ /**
3
+ * 图标栏与布局:钳制位置、贴边放下、图标的建立与高亮
4
+ *
5
+ * 只通过 ctx 读写外壳的状态与元素(getter/setter 转发),不直接碰全局。
6
+ * @module dsh-piggy/client/layout
7
+ */
8
+ import { DEV_TAB, PANEL_GAP, PANEL_MARGIN, PANEL_MIN_HEIGHT, PANEL_WIDTH, PIG_PADDING_X, SCENE_RESERVE, TABS } from './constants.js'
9
+ import { button, el } from './dom.js'
10
+
11
+ export function createLayout(ctx) {
12
+ function clampPig() {
13
+ var vw = window.innerWidth || 0
14
+ var vh = window.innerHeight || 0
15
+ if (vw <= 0 || vh <= 0) return
16
+ // Bound by the pig, not by the scene. The scene widens to the panel when
17
+ // open, and clamping against that would shove the pig sideways on any
18
+ // window resize; the panel's own overflow is `fitPanel`'s problem.
19
+ var pigRect = ctx.pig.getBoundingClientRect ? ctx.pig.getBoundingClientRect() : null
20
+ var w = (pigRect ? pigRect.width || 0 : 0) + 2 * PIG_PADDING_X
21
+ // Vertically reserve the OPEN scene, or a pig parked high up pushes its
22
+ // own hud off the top of the window the moment the panel opens.
23
+ var sceneRect = ctx.scene.getBoundingClientRect ? ctx.scene.getBoundingClientRect() : null
24
+ var h = Math.max(sceneRect ? sceneRect.height || 0 : 0, SCENE_RESERVE)
25
+ var right = Math.min(Math.max(4, ctx.userRight), Math.max(4, vw - w - 4))
26
+ // Only the pig anchors vertically. Clamping by the open panel would move
27
+ // the pig when the panel appears, which is the one thing the layout
28
+ // exists to prevent — `fitPanel` shrinks the panel instead.
29
+ var bottom = Math.min(Math.max(4, ctx.userBottom), Math.max(4, vh - h - 4))
30
+ ctx.host.style.right = Math.round(right) + 'px'
31
+ ctx.host.style.bottom = Math.round(bottom) + 'px'
32
+ }
33
+
34
+ function fitPanel() {
35
+ if (!ctx.isOpen) return
36
+ var vw = window.innerWidth || 0
37
+ var vh = window.innerHeight || 0
38
+ if (vw <= 0 || vh <= 0) return
39
+
40
+ var rect = ctx.scene.getBoundingClientRect()
41
+ var roomAbove = rect.top - PANEL_GAP - PANEL_MARGIN
42
+ var roomBelow = vh - rect.bottom - PANEL_GAP - PANEL_MARGIN
43
+
44
+ // Open on whichever side has space. Ties go above, which is where a
45
+ // bottom-docked pig expects its menu — but a pig parked near the top
46
+ // must flip, otherwise its own menu opens off the screen.
47
+ // Exactly one of top/bottom may apply. Clearing with '' would fall back
48
+ // to the stylesheet's `bottom`, leaving both set — and an absolutely
49
+ // positioned box with both edges pinned collapses to zero height.
50
+ if (roomAbove >= roomBelow) {
51
+ ctx.card.style.top = 'auto'
52
+ ctx.card.style.bottom = 'calc(100% + ' + PANEL_GAP + 'px)'
53
+ ctx.card.style.maxHeight = Math.max(PANEL_MIN_HEIGHT, Math.round(roomAbove)) + 'px'
54
+ } else {
55
+ ctx.card.style.bottom = 'auto'
56
+ ctx.card.style.top = 'calc(100% + ' + PANEL_GAP + 'px)'
57
+ ctx.card.style.maxHeight = Math.max(PANEL_MIN_HEIGHT, Math.round(roomBelow)) + 'px'
58
+ }
59
+
60
+ // Horizontal: the panel is wider than the pig, so anchoring its right
61
+ // edge to the pig can push it off the left of the window. A negative
62
+ // `right` moves the panel without touching the wrapper's width, so the
63
+ // pig stays exactly where it was put.
64
+ var width = Math.min(PANEL_WIDTH, vw - 2 * PANEL_MARGIN)
65
+ ctx.card.style.maxWidth = Math.round(width) + 'px'
66
+ var shift = PANEL_MARGIN - (rect.right - width)
67
+ ctx.card.style.right = shift > 0 ? -Math.round(shift) + 'px' : '0px'
68
+
69
+ // The hud lives inside the scene, which runs past the left edge whenever
70
+ // the panel above had to be shifted back into view. Line it up with the
71
+ // panel's left edge so it stays visible too.
72
+ var cardLeft = Math.max(rect.right - width, PANEL_MARGIN)
73
+ ctx.hud.style.left = Math.max(9, Math.round(cardLeft - rect.left)) + 'px'
74
+ }
75
+
76
+ function visibleTabs() {
77
+ return ctx.devMode ? TABS.concat([DEV_TAB]) : TABS
78
+ }
79
+
80
+ function paintBar() {
81
+ while (ctx.bar.firstChild) ctx.bar.removeChild(ctx.bar.firstChild)
82
+ var list = visibleTabs()
83
+ for (var t = 0; t < list.length; t += 1) ctx.buildIcon(list[t])
84
+ // The home screen has no icon of its own; only a vanished app (dev off) falls back.
85
+ if (ctx.icons[ctx.tab] === undefined && ctx.tab !== 'home') ctx.tab = 'home'
86
+ for (var k in ctx.icons) ctx.icons[k].setAttribute('data-active', k === ctx.tab ? 'true' : 'false')
87
+ }
88
+
89
+ function buildIcon(entry) {
90
+ (function (entry) {
91
+ var btn = button('dp-ico', { 'data-tab': entry.key }, function () {
92
+ if (ctx.host.getAttribute('data-open') !== 'true') ctx.setOpen(true)
93
+ ctx.select(entry.key)
94
+ })
95
+ btn.appendChild(el('span', 'dp-ico-e', entry.emoji))
96
+ btn.appendChild(el('span', null, entry.label))
97
+ ctx.icons[entry.key] = btn
98
+ ctx.bar.appendChild(btn)
99
+ })(entry)
100
+ }
101
+
102
+ return { clampPig, fitPanel, visibleTabs, paintBar, buildIcon }
103
+ }
@@ -0,0 +1,352 @@
1
+ // @ts-check
2
+ /**
3
+ * 把宿主快照(当前版 / 旧版 / 被截断)映射成面板画的那份形状。
4
+ *
5
+ * 缺字段补默认值,永不为 `undefined` —— 这是"满屏 undefined"那个 bug 的修法。
6
+ * @module dsh-piggy/client/normalize
7
+ */
8
+ import { MODES } from './constants.js'
9
+ import { arr, isObj, num, obj, str } from './values.js'
10
+
11
+ /**
12
+ * Map any host payload — current, older, or truncated — onto the exact
13
+ * shape the panel draws. Missing fields become defaults, never `undefined`.
14
+ * @returns {object} a fully populated view model.
15
+ */
16
+ export function normalize(raw) {
17
+ var d = obj(raw)
18
+ var pig = isObj(d.pig) ? d.pig : null
19
+ var legacy = pig !== null && !('coins' in pig) && !('health' in pig)
20
+
21
+ return {
22
+ legacy: legacy,
23
+ // Host build version, shown in the debug tab so a stale bundle is
24
+ // visible instead of being guessed at.
25
+ version: str(d.version, ''),
26
+ // Trust the flag when the host sends one. Older hosts did not, and for
27
+ // those "a pig exists" is still the right answer.
28
+ hatched: d.hatched === true || (d.hatched === undefined && pig !== null),
29
+ dead: d.dead === true || (pig !== null && num(pig.health, 5) <= 0),
30
+ pig: pig === null ? null : {
31
+ name: str(pig.name, '猪猪'),
32
+ // The pig is measured in days now; `stage` carries how big it is and
33
+ // what it looks like.
34
+ stage: {
35
+ key: str(obj(pig.stage).key, 'piglet'),
36
+ label: str(obj(pig.stage).label, '小猪'),
37
+ emoji: str(obj(pig.stage).emoji, '🐖'),
38
+ size: num(obj(pig.stage).size, 56),
39
+ line: str(obj(pig.stage).line, ''),
40
+ art: typeof obj(pig.stage).art === 'string' && obj(pig.stage).art !== '' ? obj(pig.stage).art : null,
41
+ faded: obj(pig.stage).faded === true,
42
+ // 加冕后的形态:有没有动作立绘、盖住哪些装扮位置。
43
+ actionArt: obj(pig.stage).actionArt === true,
44
+ hides: arr(obj(pig.stage).hides).map(function (slot) { return str(slot, '') }),
45
+ },
46
+ // Older hosts send no sex; the HUD then simply shows none.
47
+ sex: isObj(pig.sex) ? { key: str(pig.sex.key, ''), label: str(pig.sex.label, ''), symbol: str(pig.sex.symbol, '') } : null,
48
+ ageLabel: str(pig.ageLabel, ''),
49
+ ageForced: pig.ageForced === true,
50
+ daysToNextStage: typeof pig.daysToNextStage === 'number' ? pig.daysToNextStage : null,
51
+ soul: pig.soul === true,
52
+ mood: str(pig.mood, 'fine'),
53
+ moodEmoji: str(pig.moodEmoji, '😊'),
54
+ moodLabel: str(pig.moodLabel, '还不错'),
55
+ satiety: Math.round(num(pig.satiety, 0)),
56
+ happiness: Math.round(num(pig.happiness, 0)),
57
+ cleanliness: Math.round(num(pig.cleanliness, 0)),
58
+ health: num(pig.health, 5),
59
+ healthPercent: num(pig.healthPercent, 100),
60
+ coins: num(pig.coins, 0),
61
+ weight: str(pig.weight, '—'),
62
+ xp: num(pig.xp, 0),
63
+ // Level is driven by growth and decides the body (B2).
64
+ level: (function (info) {
65
+ var i = obj(info)
66
+ var t = obj(i.title)
67
+ return {
68
+ level: num(i.level, 1),
69
+ percent: num(i.percent, 0),
70
+ toNext: num(i.toNext, 0),
71
+ maxed: i.maxed === true,
72
+ titleLabel: str(t.label, '新来的'),
73
+ titleEmoji: str(t.emoji, '🌱'),
74
+ }
75
+ })(pig.levelInfo),
76
+ stageLine: str(pig.stageLine, ''),
77
+ illness: isObj(pig.illness) ? {
78
+ name: str(pig.illness.name, '生病'),
79
+ cure: str(pig.illness.cure, '药'),
80
+ cureEmoji: str(pig.illness.cureEmoji, '💊'),
81
+ stage: num(pig.illness.stage, 1),
82
+ doctorFee: typeof pig.illness.doctorFee === 'number' ? pig.illness.doctorFee : null,
83
+ } : null,
84
+ traits: {
85
+ intel: num(obj(pig.traits).intel, 0),
86
+ charm: num(obj(pig.traits).charm, 0),
87
+ strong: num(obj(pig.traits).strong, 0),
88
+ },
89
+ courses: obj(pig.courses),
90
+ // Souvenirs are objects now (rarity + story). An old host sent bare
91
+ // strings, and those must still list rather than turn into [object
92
+ // Object] or vanish.
93
+ souvenirs: arr(pig.souvenirs).map(entry => {
94
+ if (typeof entry === 'string') {
95
+ return { key: entry, emoji: '🎁', label: entry, rarityLabel: '普通', rarityEmoji: '⚪', price: 0, story: '', fromLabel: '' }
96
+ }
97
+ return {
98
+ key: str(obj(entry).key, ''),
99
+ emoji: str(obj(entry).emoji, '🎁'),
100
+ label: str(obj(entry).label, '纪念品'),
101
+ rarityLabel: str(obj(entry).rarityLabel, '普通'),
102
+ rarityEmoji: str(obj(entry).rarityEmoji, '⚪'),
103
+ price: num(obj(entry).price, 0),
104
+ story: str(obj(entry).story, ''),
105
+ fromLabel: str(obj(entry).fromLabel, ''),
106
+ }
107
+ }).filter(entry => entry.key !== ''),
108
+ memories: arr(pig.memories).filter(m => typeof m === 'string'),
109
+ },
110
+ actions: normalizeActions(d.actions),
111
+ jobs: arr(d.jobs).map(job => ({
112
+ key: str(obj(job).key, ''),
113
+ label: str(obj(job).label, '工作'),
114
+ emoji: str(obj(job).emoji, '💼'),
115
+ minutes: num(obj(job).minutes, 0),
116
+ coins: num(obj(job).coins, 0),
117
+ available: obj(job).available === true,
118
+ // What schooling has bought this job.
119
+ traitLabel: str(obj(job).traitLabel, ''),
120
+ traitEmoji: str(obj(job).traitEmoji, ''),
121
+ traitPoints: num(obj(job).traitPoints, 0),
122
+ baseMinutes: num(obj(job).baseMinutes, 0),
123
+ baseCoins: num(obj(job).baseCoins, 0),
124
+ payPercent: num(obj(job).payPercent, 0),
125
+ speedPercent: num(obj(job).speedPercent, 0),
126
+ // An old host has no gate at all, so a missing flag must read as
127
+ // "qualified" — the opposite default would lock every job on upgrade.
128
+ qualified: obj(job).qualified !== false,
129
+ lockText: str(obj(job).lockText, ''),
130
+ level: num(obj(job).level, 1),
131
+ trait: str(obj(job).trait, ''),
132
+ satiety: num(obj(job).satiety, 0),
133
+ cleanliness: num(obj(job).cleanliness, 0),
134
+ requirements: arr(obj(job).requirements).filter(isObj).map(entry => ({
135
+ text: str(entry.text, ''),
136
+ need: num(entry.need, 0),
137
+ have: num(entry.have, 0),
138
+ kind: str(entry.kind, ''),
139
+ met: entry.met === true,
140
+ })),
141
+ })).filter(job => job.key !== ''),
142
+ // B4: nine subjects, each with its own lesson count and stage.
143
+ subjects: arr(d.subjects).map(sub => ({
144
+ key: str(obj(sub).key, ''),
145
+ label: str(obj(sub).label, '课'),
146
+ emoji: str(obj(sub).emoji, '📘'),
147
+ traitLabel: str(obj(sub).traitLabel, ''),
148
+ traitEmoji: str(obj(sub).traitEmoji, ''),
149
+ lessons: num(obj(sub).lessons, num(obj(sub).level, 0)),
150
+ stageKey: str(obj(obj(sub).stage).key, ''),
151
+ stageLabel: str(obj(obj(sub).stage).label, ''),
152
+ graduatedLabel: isObj(obj(sub).graduated) ? str(obj(sub).graduated.label, '') : '',
153
+ nextGraduation: typeof obj(sub).nextGraduation === 'number' ? obj(sub).nextGraduation : null,
154
+ minutes: num(obj(sub).minutes, 0),
155
+ tuition: num(obj(sub).tuition, 0),
156
+ gain: num(obj(sub).gain, 0),
157
+ secondaryGain: num(obj(sub).secondaryGain, 0),
158
+ available: obj(sub).available === true,
159
+ affordable: obj(sub).affordable !== false,
160
+ })).filter(sub => sub.key !== ''),
161
+ // 兴趣课:学习页里随时能学的一栏,学完加的是既有的三条属性。
162
+ interests: arr(d.interests).map(entry => ({
163
+ key: str(obj(entry).key, ''),
164
+ label: str(obj(entry).label, '兴趣'),
165
+ emoji: str(obj(entry).emoji, '🎯'),
166
+ traitLabel: str(obj(entry).traitLabel, ''),
167
+ traitEmoji: str(obj(entry).traitEmoji, ''),
168
+ minutes: num(obj(entry).minutes, 0),
169
+ cost: num(obj(entry).cost, 0),
170
+ gain: num(obj(entry).gain, 0),
171
+ blurb: str(obj(entry).blurb, ''),
172
+ times: num(obj(entry).times, 0),
173
+ certificate: str(obj(entry).certificate, ''),
174
+ certificateAfter: num(obj(entry).certificateAfter, 0),
175
+ certified: obj(entry).certified === true,
176
+ available: obj(entry).available === true,
177
+ affordable: obj(entry).affordable === true,
178
+ })).filter(entry => entry.key !== ''),
179
+ stages: arr(d.stages).map(stage => ({
180
+ key: str(obj(stage).key, ''),
181
+ label: str(obj(stage).label, '学段'),
182
+ emoji: str(obj(stage).emoji, '📚'),
183
+ minutes: num(obj(stage).minutes, 0),
184
+ tuition: num(obj(stage).tuition, 0),
185
+ gain: num(obj(stage).gain, 0),
186
+ // B4: the lesson numbers this stage covers (upTo null = no end).
187
+ from: num(obj(stage).from, 0),
188
+ upTo: typeof obj(stage).upTo === 'number' ? obj(stage).upTo : null,
189
+ // Which courses this stage teaches — empty on an old host, in which
190
+ // case the panel shows every subject rather than none.
191
+ subjects: arr(obj(stage).subjects).filter(key => typeof key === 'string'),
192
+ // The school ladder: a stage with `unlocked === false` is gated behind
193
+ // finishing the previous one, and says by how much.
194
+ unlocked: obj(stage).unlocked !== false,
195
+ progress: isObj(obj(stage).progress) ? {
196
+ done: num(obj(stage).progress.done, 0),
197
+ need: num(obj(stage).progress.need, 0),
198
+ label: str(obj(stage).progress.label, ''),
199
+ } : null,
200
+ })).filter(stage => stage.key !== ''),
201
+ trips: arr(d.trips).map(trip => ({
202
+ key: str(obj(trip).key, ''),
203
+ label: str(obj(trip).label, '目的地'),
204
+ emoji: str(obj(trip).emoji, '🧳'),
205
+ minutes: num(obj(trip).minutes, 0),
206
+ cost: num(obj(trip).cost, 0),
207
+ happiness: num(obj(trip).happiness, 0),
208
+ // What the destination can bring back — the far trips advertise it.
209
+ souvenirCount: num(obj(trip).souvenirCount, 0),
210
+ bestRarity: str(obj(trip).bestRarity, ''),
211
+ bestRarityEmoji: str(obj(trip).bestRarityEmoji, ''),
212
+ affordable: obj(trip).affordable === true,
213
+ available: obj(trip).available === true,
214
+ })).filter(trip => trip.key !== ''),
215
+ // 家当: owned and worn, never counted. An old host sends none.
216
+ dress: arr(d.dress).map(entry => ({
217
+ key: str(obj(entry).key, ''),
218
+ label: str(obj(entry).label, '装扮'),
219
+ emoji: str(obj(entry).emoji, '👕'),
220
+ price: num(obj(entry).price, 0),
221
+ level: num(obj(entry).level, 1),
222
+ slot: str(obj(entry).slot, ''),
223
+ slotLabel: str(obj(entry).slotLabel, ''),
224
+ blurb: str(obj(entry).blurb, ''),
225
+ owned: obj(entry).owned === true,
226
+ worn: obj(entry).worn === true,
227
+ unlocked: obj(entry).unlocked !== false,
228
+ })).filter(entry => entry.key !== ''),
229
+ shop: arr(d.shop).map(item => ({
230
+ key: str(obj(item).key, ''),
231
+ label: str(obj(item).label, '物品'),
232
+ emoji: str(obj(item).emoji, '📦'),
233
+ price: num(obj(item).price, 0),
234
+ kind: str(obj(item).kind, 'food'),
235
+ tier: typeof obj(item).tier === 'number' ? obj(item).tier : null,
236
+ // 家当 fields: a dress item is owned (not counted) or waits for a level.
237
+ level: typeof obj(item).level === 'number' ? obj(item).level : null,
238
+ owned: obj(item).owned === true,
239
+ worn: obj(item).worn === true,
240
+ unlocked: obj(item).unlocked !== false,
241
+ blurb: str(obj(item).blurb, ''),
242
+ affordable: obj(item).affordable === true,
243
+ needed: obj(item).needed === true,
244
+ })).filter(item => item.key !== ''),
245
+ inventory: obj(d.inventory),
246
+ daily: {
247
+ canSignIn: obj(d.daily).canSignIn === true,
248
+ signInDay: num(obj(d.daily).signInDay, 1),
249
+ signInTotal: num(obj(d.daily).signInTotal, 0),
250
+ cycle: num(obj(d.daily).cycle, 12),
251
+ unclaimed: num(obj(d.daily).unclaimed, 0),
252
+ onlineMinutes: num(obj(d.daily).onlineMinutes, 0),
253
+ },
254
+ // 新到旧;老宿主没有 diary 时是空数组,面板不显示这一栏。
255
+ diary: arr(d.diary).map(entry => ({
256
+ day: str(obj(entry).day, ''),
257
+ text: str(obj(entry).text, ''),
258
+ })).filter(entry => entry.day !== '' && entry.text !== ''),
259
+ // Which items each care action could spend right now.
260
+ care: (() => {
261
+ const out = {}
262
+ const source = obj(d.care)
263
+ for (const action of ['feed', 'bathe', 'play']) {
264
+ out[action] = arr(source[action]).map(entry => ({
265
+ key: str(obj(entry).key, ''),
266
+ label: str(obj(entry).label, '物品'),
267
+ emoji: str(obj(entry).emoji, '📦'),
268
+ default: obj(entry).default === true,
269
+ count: typeof obj(entry).count === 'number' ? obj(entry).count : null,
270
+ satiety: num(obj(entry).satiety, 0),
271
+ happiness: num(obj(entry).happiness, 0),
272
+ cleanliness: num(obj(entry).cleanliness, 0),
273
+ })).filter(entry => entry.key !== '')
274
+ }
275
+ return out
276
+ })(),
277
+ activity: isObj(d.activity) ? {
278
+ kind: str(d.activity.kind, 'work'),
279
+ key: str(d.activity.key, ''),
280
+ label: str(d.activity.label, '外面'),
281
+ emoji: str(d.activity.emoji, '💼'),
282
+ secondsLeft: num(d.activity.secondsLeft, 0),
283
+ progress: num(d.activity.progress, 0),
284
+ } : null,
285
+ canGoOut: d.canGoOut === true,
286
+ timeScale: num(d.timeScale, 1),
287
+ boxStage: isObj(d.boxStage) ? {
288
+ key: str(d.boxStage.key, 'box'),
289
+ label: str(d.boxStage.label, '纸盒'),
290
+ emoji: str(d.boxStage.emoji, '📦'),
291
+ size: num(d.boxStage.size, 58),
292
+ } : { key: 'box', label: '纸盒', emoji: '📦', size: 58 },
293
+ awayBlocked: typeof d.awayBlocked === 'string' ? d.awayBlocked : null,
294
+ // B9: the villager card. Older hosts send none, and the card says so.
295
+ profile: isObj(d.profile) ? {
296
+ personality: isObj(d.profile.personality) ? { label: str(d.profile.personality.label, ''), emoji: str(d.profile.personality.emoji, '') } : null,
297
+ catchphrase: str(d.profile.catchphrase, ''),
298
+ motto: str(d.profile.motto, ''),
299
+ birthday: str(d.profile.birthday, ''),
300
+ zodiac: isObj(d.profile.zodiac) ? { label: str(d.profile.zodiac.label, ''), emoji: str(d.profile.zodiac.emoji, '') } : null,
301
+ counts: {
302
+ days: num(obj(d.profile.counts).days, 0),
303
+ certificates: num(obj(d.profile.counts).certificates, 0),
304
+ souvenirs: num(obj(d.profile.counts).souvenirs, 0),
305
+ graduations: num(obj(d.profile.counts).graduations, 0),
306
+ },
307
+ } : null,
308
+ // 加冕: the forms and how close the pig is. Older hosts send none.
309
+ forms: isObj(d.forms) ? {
310
+ current: typeof d.forms.current === 'string' ? d.forms.current : null,
311
+ forms: arr(d.forms.forms).map(function (raw) {
312
+ var f = obj(raw)
313
+ return {
314
+ key: str(f.key, ''), label: str(f.label, ''), emoji: str(f.emoji, '👑'), art: str(f.art, ''),
315
+ current: f.current === true, ready: f.ready === true,
316
+ requirements: arr(f.requirements).map(function (row) {
317
+ var r = obj(row)
318
+ return { key: str(r.key, ''), label: str(r.label, ''), have: num(r.have, 0), need: num(r.need, 0), met: r.met === true }
319
+ }),
320
+ }
321
+ }),
322
+ } : null,
323
+ // B6: what the pig calls its owner, and 免打扰. Older hosts send neither.
324
+ dialogue: {
325
+ ownerName: str(obj(d.dialogue).ownerName, '主人'),
326
+ quiet: obj(d.dialogue).quiet === true,
327
+ },
328
+ pending: arr(d.pending).filter(e => isObj(e) && typeof e.at === 'number').map(e => ({
329
+ id: num(e.id, 0),
330
+ kind: str(e.kind, ''),
331
+ text: str(e.text, ''),
332
+ at: e.at,
333
+ replies: arr(e.replies).filter(label => typeof label === 'string'),
334
+ })),
335
+ maxHealth: num(d.maxHealth, 5),
336
+ }
337
+ }
338
+
339
+ export function normalizeActions(raw) {
340
+ var source = obj(raw)
341
+ var out = {}
342
+ for (var i = 0; i < MODES.length; i += 1) {
343
+ var key = MODES[i]
344
+ var entry = obj(source[key])
345
+ out[key] = {
346
+ ready: entry.ready !== false,
347
+ waitSeconds: num(entry.waitSeconds, 0),
348
+ blocked: typeof entry.blocked === 'string' ? entry.blocked : null,
349
+ }
350
+ }
351
+ return out
352
+ }