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
package/store/api.js ADDED
@@ -0,0 +1,260 @@
1
+ // @ts-check
2
+ /**
3
+ * store 的方法表:每个方法都转发给 core,并在成功后安排一次保存。
4
+ *
5
+ * 方法表单独成文件,是为了让 store.js 只剩"打开存档 + 节流写盘"这一件事
6
+ * (见 docs/CONVENTIONS.md 的单一职责与函数长度条款)。
7
+ *
8
+ * @module dsh-piggy/store/api
9
+ */
10
+ import {
11
+ act as coreAct,
12
+ adopt as coreAdopt,
13
+ ageFromNow as coreAgeFromNow,
14
+ applyDevPatch as coreDevPatch,
15
+ buy as coreBuy,
16
+ callOffActivity as coreCallOff,
17
+ decay,
18
+ drainPending,
19
+ feed as coreFeed,
20
+ grantAll as coreGrantAll,
21
+ hatch as coreHatch,
22
+ hatchEgg,
23
+ chat as coreChat,
24
+ rename as coreRename,
25
+ replyToLine as coreReplyToLine,
26
+ openGift as coreOpenGift,
27
+ writeDiaryIfNewDay as coreWriteDiary,
28
+ recordOnline as coreRecordOnline,
29
+ signIn as coreSignIn,
30
+ reset as coreReset,
31
+ seeDoctor as coreSeeDoctor,
32
+ setCatchphrase as coreSetCatchphrase,
33
+ setMotto as coreSetMotto,
34
+ crown as coreCrown,
35
+ setOwnerName as coreSetOwnerName,
36
+ setQuiet as coreSetQuiet,
37
+ sellSouvenir as coreSellSouvenir,
38
+ setTimeScale as coreSetTimeScale,
39
+ startInterest as coreStartInterest,
40
+ startStudy as coreStartStudy,
41
+ startTrip as coreStartTrip,
42
+ startWork as coreStartWork,
43
+ takeOff as coreTakeOff,
44
+ useItem as coreUseItem,
45
+ wearItem as coreWearItem,
46
+ } from '../core.js'
47
+
48
+ /**
49
+ * @typedef {object} StoreControl
50
+ * @property {string} filePath - where this pig is saved
51
+ * @property {() => number} now - injectable clock
52
+ * @property {() => object|null} getState - live state (null until an egg is laid)
53
+ * @property {(next: object|null) => void} setState - replace the live state
54
+ * @property {() => void} scheduleSave - request a throttled write
55
+ * @property {(fn: (state: object) => object) => object} mutate - run a core mutator, saving on success
56
+ * @property {() => void} dispose - flush and stop the timer
57
+ */
58
+
59
+ /**
60
+ * Build the store's public methods.
61
+ * @param {StoreControl} control
62
+ * @returns {object} the store API
63
+ */
64
+ export function createApi(control) {
65
+ const { filePath, now, getState, setState, scheduleSave, mutate, dispose } = control
66
+
67
+ /** 上一次「面板轮询」的时刻,用来算在线时长;0 = 还没见过第一次。 */
68
+ let lastPollMs = 0
69
+
70
+ return {
71
+ /** The live state (null until an egg is laid). Exposed for rendering. */
72
+ get state() { return getState() },
73
+
74
+ /** Where this pig is saved. */
75
+ get filePath() { return filePath },
76
+
77
+ /** Digest one observed harness event; silently ignored before hatching. */
78
+ feed(event) {
79
+ const state = getState()
80
+ if (state === null) return []
81
+ try {
82
+ const crossed = coreFeed(state, event, now())
83
+ scheduleSave()
84
+ return crossed
85
+ } catch (error) {
86
+ console.warn(`[dsh-piggy] feed failed: event="${event}" reason="${error instanceof Error ? error.message : String(error)}"`)
87
+ return []
88
+ }
89
+ },
90
+
91
+ /** Fold wall-clock decay in (and resolve work/illness) then hand back state. */
92
+ freshen() {
93
+ const state = getState()
94
+ if (state === null) return null
95
+ try {
96
+ const nowMs = now()
97
+ // 在线时长按真实时间累计(与调试页的时间倍率无关);每次读状态就是
98
+ // 一次「面板还在轮询」的证据。
99
+ coreRecordOnline(state, lastPollMs, nowMs)
100
+ lastPollMs = nowMs
101
+ // 跨过 06:00 之后第一次读状态,就把前一天写成一篇日记。
102
+ coreWriteDiary(state, nowMs)
103
+ decay(state, nowMs)
104
+ scheduleSave()
105
+ } catch (error) {
106
+ // Keep the stale-but-valid state, but say why it is stale.
107
+ console.warn(`[dsh-piggy] decay failed: reason="${error instanceof Error ? error.message : String(error)}"`)
108
+ }
109
+ return getState()
110
+ },
111
+
112
+ /** Apply one care action with its cooldown, spending `itemKey` when given. */
113
+ act: (action, itemKey) => mutate(live => coreAct(live, action, now(), itemKey)),
114
+
115
+ /** Send the pig out to work. */
116
+ startWork: jobKey => mutate(live => coreStartWork(live, jobKey, now())),
117
+
118
+ /** Send the pig to class. */
119
+ startStudy: (subjectKey, stageKey) => mutate(live => coreStartStudy(live, subjectKey, stageKey, now())),
120
+
121
+ /** Send the pig to an 兴趣课. */
122
+ startInterest: interestKey => mutate(live => coreStartInterest(live, interestKey, now())),
123
+
124
+ /** Debug: one of everything (consumables, 装扮, coins). */
125
+ grantAll: () => mutate(live => coreGrantAll(live, now())),
126
+
127
+ /** Send the pig travelling. */
128
+ startTrip: tripKey => mutate(live => coreStartTrip(live, tripKey, now())),
129
+
130
+ /** Bring the pig home early (work forfeits pay; study/trips are refunded). */
131
+ callOffActivity: () => mutate(live => coreCallOff(live, now())),
132
+
133
+ /** Back-compat alias. */
134
+ callOffWork: () => mutate(live => coreCallOff(live, now())),
135
+
136
+ /** Buy one item into the backpack. */
137
+ buy: itemKey => mutate(live => coreBuy(live, itemKey, now())),
138
+
139
+ /** Use one item from the backpack. */
140
+ useItem: itemKey => mutate(live => coreUseItem(live, itemKey, now())),
141
+
142
+ /** 看医生: pay to be cured without buying the medicine. */
143
+ seeDoctor: () => mutate(live => coreSeeDoctor(live, now())),
144
+ sellSouvenir: souvenirKey => mutate(live => coreSellSouvenir(live, souvenirKey, now())),
145
+
146
+ /** The pig speaks up on its own: 'enter' after a while away, or 'idle'. */
147
+ chat: reason => mutate(live => coreChat(live, reason === 'enter' ? 'enter' : 'idle', now())),
148
+
149
+ /** 免打扰 on or off. */
150
+ setQuiet: on => mutate(live => coreSetQuiet(live, on)),
151
+
152
+ /** The pig's catchphrase (居民卡). */
153
+ setCatchphrase: text => mutate(live => coreSetCatchphrase(live, text)),
154
+
155
+ /** The line on the pig's card (居民卡). */
156
+ setMotto: text => mutate(live => coreSetMotto(live, text)),
157
+
158
+ /** 加冕: the owner picks a form the pig qualifies for. */
159
+ crown: formKey => mutate(live => coreCrown(live, now(), formKey || undefined)),
160
+
161
+ /** What the pig calls its owner. */
162
+ setOwnerName: name => mutate(live => coreSetOwnerName(live, name)),
163
+
164
+ /** The owner answers the pig's latest line. */
165
+ reply: (lineId, replyIndex) => mutate(live => coreReplyToLine(live, lineId, replyIndex)),
166
+
167
+ /** 领今天的签到礼包。 */
168
+ signIn: () => mutate(live => coreSignIn(live, now())),
169
+
170
+ /** 开一个攒着的在线礼包。 */
171
+ openGift: () => mutate(live => coreOpenGift(live, now())),
172
+ wear: (itemKey, on) => mutate(live => (on ? coreWearItem(live, itemKey, now()) : coreTakeOff(live, itemKey, now()))),
173
+
174
+ /** Open the box. Only works when there is no living pig yet. */
175
+ hatch() {
176
+ const state = getState()
177
+ // A save that exists but is not hatched is a box — reset and adopt both
178
+ // produce one. Refusing because `state !== null` left the box unopenable.
179
+ if (state !== null && state.hatched === true) return false
180
+ setState(state === null ? hatchEgg(now()) : coreHatch(state, now()))
181
+ scheduleSave()
182
+ return true
183
+ },
184
+
185
+ /** Change how fast the pig ages. */
186
+ setTimeScale(scale) {
187
+ const state = getState()
188
+ if (state === null) return false
189
+ setState(coreSetTimeScale(state, scale, now()))
190
+ scheduleSave()
191
+ return true
192
+ },
193
+
194
+ /** Put the age back on the real clock. */
195
+ ageFromNow() {
196
+ const state = getState()
197
+ if (state === null) return false
198
+ setState(coreAgeFromNow(state, now()))
199
+ scheduleSave()
200
+ return true
201
+ },
202
+
203
+ /** Developer mode: force the pig into any state. */
204
+ dev(patch) {
205
+ // Through mutate(): a patch that blows up halfway must not be written.
206
+ return mutate(live => { coreDevPatch(live, patch, now()); return true }) === true
207
+ },
208
+
209
+ /** Start from a brand new box, living pig or not. */
210
+ reset() {
211
+ setState(coreReset(now()))
212
+ scheduleSave()
213
+ return true
214
+ },
215
+
216
+ /**
217
+ * Start over with a fresh box, keeping the old pig's memories. Only offered
218
+ * once a pig has died — you cannot throw a living one away.
219
+ */
220
+ adopt() {
221
+ const state = getState()
222
+ if (state === null) return false
223
+ if (state.dead !== true) return false
224
+ setState(coreAdopt(state, now()))
225
+ scheduleSave()
226
+ return true
227
+ },
228
+
229
+ /** Rename the pig; null when the name is unusable or the pig is absent. */
230
+ rename(rawName) {
231
+ const state = getState()
232
+ if (state === null) return null
233
+ try {
234
+ const cleaned = coreRename(state, rawName, now())
235
+ if (cleaned !== null) scheduleSave()
236
+ return cleaned
237
+ } catch (error) {
238
+ console.warn(`[dsh-piggy] rename failed: reason="${error instanceof Error ? error.message : String(error)}"`)
239
+ return null
240
+ }
241
+ },
242
+
243
+ /** Take the queued announcements (work done, got sick, got better…). */
244
+ drainPending() {
245
+ const state = getState()
246
+ if (state === null) return []
247
+ try {
248
+ const events = drainPending(state)
249
+ if (events.length > 0) scheduleSave()
250
+ return events
251
+ } catch (error) {
252
+ console.warn(`[dsh-piggy] drainPending failed: reason="${error instanceof Error ? error.message : String(error)}"`)
253
+ return []
254
+ }
255
+ },
256
+
257
+ /** Flush any pending write and stop the timer. Called on plugin unload. */
258
+ dispose,
259
+ }
260
+ }
@@ -0,0 +1,129 @@
1
+ // @ts-check
2
+ /**
3
+ * 存档的读写:原子写、损坏时保留现场。这一层不含任何业务规则。
4
+ *
5
+ * @module dsh-piggy/store/state-file
6
+ */
7
+ import { closeSync, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, writeSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { dirname, join, resolve } from 'node:path'
10
+
11
+ import { STATE_VERSION, migrate } from '../core.js'
12
+
13
+ /** The harness home, matching the launcher's own resolution. */
14
+ export function dshHome() {
15
+ const configured = process.env.DSH_HOME
16
+ return configured !== undefined && configured.trim() !== ''
17
+ ? resolve(configured.trim())
18
+ : join(homedir(), '.dsh')
19
+ }
20
+
21
+ /** Default save location. */
22
+ export function defaultStatePath() {
23
+ return join(dshHome(), 'dsh-piggy', 'state.json')
24
+ }
25
+
26
+ /**
27
+ * The save used to live in `$DSH_HOME/dsh-pig/`. Move it to `dsh-piggy/` once:
28
+ * copy the whole folder (backups too), then rename the old one aside rather
29
+ * than deleting it. Does nothing when the new folder already exists.
30
+ * @returns {string | null} where the old folder went, or null if nothing moved
31
+ */
32
+ export function moveLegacySaveDir(home = dshHome(), nowMs = Date.now()) {
33
+ const fresh = join(home, 'dsh-piggy')
34
+ const legacy = join(home, 'dsh-pig')
35
+ if (existsSync(fresh) || !existsSync(legacy)) return null
36
+ try {
37
+ cpSync(legacy, fresh, { recursive: true, errorOnExist: true })
38
+ const aside = `${legacy}.moved-${new Date(nowMs).toISOString().replace(/[:.]/g, '-')}`
39
+ renameSync(legacy, aside)
40
+ console.warn(`[dsh-piggy] save folder moved: "${legacy}" -> "${fresh}" (old folder kept as "${aside}")`)
41
+ return aside
42
+ } catch (error) {
43
+ console.warn(`[dsh-piggy] could not move the old save folder: reason="${error instanceof Error ? error.message : String(error)}"`)
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Copy an unusable save next to the original, then complain loudly. */
49
+ function preserveUnusableSave(filePath, raw, reason, nowMs) {
50
+ const backup = `${filePath}.corrupt-${new Date(nowMs).toISOString().replace(/[:.]/g, '-')}`
51
+ try {
52
+ writeFileSync(backup, raw)
53
+ } catch (error) {
54
+ console.warn(`[dsh-piggy] save unusable (${reason}) and the backup failed: path="${filePath}" reason="${error instanceof Error ? error.message : String(error)}"`)
55
+ return
56
+ }
57
+ console.warn(`[dsh-piggy] save unusable (${reason}); kept a copy at "${backup}" and left the original untouched`)
58
+ }
59
+
60
+ /**
61
+ * Keep a copy of a save before it is upgraded to a newer version.
62
+ *
63
+ * An upgrade rewrites the file on the next save; if a step turns out to be
64
+ * wrong, this copy is the only way back to the pig as it was.
65
+ */
66
+ function keepPreUpgradeCopy(filePath, raw, fromVersion, nowMs) {
67
+ const backup = `${filePath}.v${fromVersion}-backup-${new Date(nowMs).toISOString().replace(/[:.]/g, '-')}`
68
+ try {
69
+ writeFileSync(backup, raw)
70
+ console.warn(`[dsh-piggy] upgrading save v${fromVersion} -> v${STATE_VERSION}; kept a copy at "${backup}"`)
71
+ } catch (error) {
72
+ console.warn(`[dsh-piggy] upgrading save v${fromVersion} -> v${STATE_VERSION} without a backup: path="${filePath}" reason="${error instanceof Error ? error.message : String(error)}"`)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Read the save, keeping the evidence when it cannot be used.
78
+ *
79
+ * A bare `catch { return null }` used to turn a corrupt or unreadable save into
80
+ * "no pig" — the pig silently vanished and the next write would have buried the
81
+ * old file.
82
+ *
83
+ * @returns {{ state: object|null, needsSave: boolean }} the migrated state (or
84
+ * null) and whether the file was on an older version and wants a rewrite.
85
+ */
86
+ export function readStateFile(filePath, nowMs) {
87
+ let raw
88
+ try {
89
+ raw = readFileSync(filePath, 'utf8')
90
+ } catch (error) {
91
+ // A missing save is the normal first run; anything else is worth saying.
92
+ const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
93
+ if (code !== 'ENOENT') {
94
+ console.warn(`[dsh-piggy] could not read save: path="${filePath}" reason="${error instanceof Error ? error.message : String(error)}"`)
95
+ }
96
+ return { state: null, needsSave: false }
97
+ }
98
+
99
+ let parsed
100
+ try {
101
+ parsed = JSON.parse(raw)
102
+ } catch (error) {
103
+ preserveUnusableSave(filePath, raw, `invalid JSON: ${error instanceof Error ? error.message : String(error)}`, nowMs)
104
+ return { state: null, needsSave: false }
105
+ }
106
+
107
+ const upgraded = migrate(parsed, nowMs)
108
+ if (upgraded === null) {
109
+ preserveUnusableSave(filePath, raw, 'migrate() rejected the shape', nowMs)
110
+ return { state: null, needsSave: false }
111
+ }
112
+ const onDisk = parsed?.version
113
+ if (typeof onDisk === 'number' && onDisk < STATE_VERSION) keepPreUpgradeCopy(filePath, raw, onDisk, nowMs)
114
+ return { state: upgraded, needsSave: parsed?.version !== upgraded.version }
115
+ }
116
+
117
+ /** Atomic write: temp file → fsync → rename. */
118
+ export function writeStateFile(filePath, state) {
119
+ mkdirSync(dirname(filePath), { recursive: true })
120
+ const tmp = `${filePath}.tmp`
121
+ const fd = openSync(tmp, 'w')
122
+ try {
123
+ writeSync(fd, JSON.stringify(state, null, 2))
124
+ fsyncSync(fd)
125
+ } finally {
126
+ closeSync(fd)
127
+ }
128
+ renameSync(tmp, filePath)
129
+ }
package/store.js ADDED
@@ -0,0 +1,96 @@
1
+ // @ts-check
2
+ /**
3
+ * The pig's save: open it, keep the pig's whole state in memory, and write it
4
+ * back on a throttle.
5
+ *
6
+ * Only IO and lifetime live here — the method table is in store/api.js and the
7
+ * actual game rules are in core/ (see docs/CONVENTIONS.md).
8
+ *
9
+ * @module dsh-piggy/store
10
+ */
11
+ import { createApi } from './store/api.js'
12
+ import { defaultStatePath, dshHome, moveLegacySaveDir, readStateFile, writeStateFile } from './store/state-file.js'
13
+
14
+ export { defaultStatePath, dshHome, moveLegacySaveDir }
15
+
16
+ const SAVE_THROTTLE_MS = 1500
17
+
18
+ /**
19
+ * Open (or lazily create on first hatch) the save file.
20
+ * @param {string} [filePath] - where the save lives.
21
+ * @param {object} [options] - test seams.
22
+ * @param {() => number} [options.now] - injectable clock.
23
+ * @param {Function} [options.setTimer] - injectable scheduler.
24
+ * @param {Function} [options.clearTimer] - injectable canceller.
25
+ */
26
+ export function createStore(filePath = defaultStatePath(), options = {}) {
27
+ const now = options.now ?? (() => Date.now())
28
+ const setTimer = options.setTimer ?? setTimeout
29
+ const clearTimer = options.clearTimer ?? clearTimeout
30
+
31
+ let dirty = false
32
+ let timer = null
33
+ let state = null
34
+
35
+ function scheduleSave() {
36
+ dirty = true
37
+ if (timer !== null) return
38
+ timer = setTimer(() => {
39
+ timer = null
40
+ try {
41
+ writeNow()
42
+ } catch (error) {
43
+ // A failed write must not break the harness, but it must not be silent
44
+ // either: the player would keep playing against a save that is not there.
45
+ console.warn(`[dsh-piggy] save failed: path="${filePath}" reason="${error instanceof Error ? error.message : String(error)}"`)
46
+ }
47
+ }, SAVE_THROTTLE_MS)
48
+ if (typeof timer?.unref === 'function') timer.unref()
49
+ }
50
+
51
+ function writeNow() {
52
+ if (!dirty || state === null) return
53
+ writeStateFile(filePath, state)
54
+ dirty = false
55
+ }
56
+
57
+ /** Run a core mutator, saving when it reports success. */
58
+ function mutate(fn) {
59
+ if (state === null) return { ok: false, reason: 'absent' }
60
+ // A core call that throws halfway used to leave the half-applied state in
61
+ // memory — and the next save wrote it out. Snapshot first, restore on throw.
62
+ const before = structuredClone(state)
63
+ try {
64
+ const result = fn(state)
65
+ scheduleSave()
66
+ return result
67
+ } catch (error) {
68
+ state = before
69
+ return { ok: false, reason: 'error', message: error instanceof Error ? error.message : String(error) }
70
+ }
71
+ }
72
+
73
+ const opened = readStateFile(filePath, now())
74
+ state = opened.state
75
+ dirty = opened.needsSave
76
+
77
+ return createApi({
78
+ filePath,
79
+ now,
80
+ getState: () => state,
81
+ setState: next => { state = next },
82
+ scheduleSave,
83
+ mutate,
84
+ dispose() {
85
+ if (timer !== null) {
86
+ clearTimer(timer)
87
+ timer = null
88
+ }
89
+ try {
90
+ writeNow()
91
+ } catch (error) {
92
+ console.warn(`[dsh-piggy] final save failed: path="${filePath}" reason="${error instanceof Error ? error.message : String(error)}"`)
93
+ }
94
+ },
95
+ })
96
+ }